用 SVG 手撸一个思维导图

实现思维导图思路

  1. 思维导图是通过根节点分叉出多个子节点,每个子节点又可以分叉出更多的子节点,形成一个树状结构。
  2. 使用 Svg 实现时,可以通过 rect 来绘制节点.
  3. 通过节点的relationTypes 属性来表示节点之间的关系。计算出节点和节点之间的连线。可以使用三次贝塞尔曲线来实现。
  4. 当我们添加、删除节点时,需要重新计算关联节点的相关信息(显示、位置、关系线)等。例如删除父节点的时候,需要删除父节点的子节点。
  5. 双击节点可以进入编辑状态。输入完更新节点文本信息。

通过上面的思路,我们定义了一个节点 Shape Interface。至于关系线,可以通过 shape.bounds 和 shape.style.relationTypes 找到对应的关联节点动态计算出来,后面会说。

ts 复制代码
interface Shape {
  // 节点位置大小信息
  bounds: {
    absX: number;
    absY: number;
    width: number;
    height: number;
  };
  // 名称输入框位置大小信息,名称输入不是从矩形左上角开始,而是从名称输入框左上角开始
  nameBounds: {
    absX: number;
    absY: number;
    width: number;
    height: number;
  };
  modelName: string; // 节点名称
  // 矩形框相关样式
  style: {
    fontSize: number;
    stroke: string;
    strokeWidth: number;
    expand: boolean; // 节点是否展开,在有子子项时候有用
    relationTypes: { shapeId: string }[]; // 关联的子项
    shapeDepth: number; // 节点深度
  };
  isDelete: boolean
}

代码实现

展开和收起

  1. 当节点下有子节点时,我们可以点击收起 icon 将子节点 isDelete 设置为 true。
  2. 更新 icon 状态,显示收起节点的个数。
TS 复制代码
  shape.style.relationTypes.forEach(item =>{
      const childShape = shapeMap.get(item.shapeId);
      /** 删除或者显示子节点 */
      childShape.isDelete = !expand;
      toUpdateShapeSet.add(childShape)
      if (childShape.style.relationTypes) {
        // 递归更新子节点的展开状态
        this.updateShapeExpand(childShape, toUpdateShapeSet, shapeMap, expand)
      }
    }) 
  }

拖动、缩放

思路:通过 g 元素作为根元素包裹。当移动和缩放时,将对应的值应用到 g 标签上。 <g :transform="`matrix( <math xmlns="http://www.w3.org/1998/Math/MathML"> s c a l e , 0 , 0 , {scale}, 0, 0, </math>scale,0,0,{scale}, <math xmlns="http://www.w3.org/1998/Math/MathML"> t r a n s f o r m . x , {transform.x}, </math>transform.x,{transform.y})`> 组件</g>"

  1. 记录鼠标点击的位置。
  2. 记录拖动鼠标的位置。
  3. 更新鼠标的偏移量。
  4. 滚动鼠标记录 scale 缩放值。
  5. 使用 g 标签包裹 svg 下所有元素,将偏移量应用于 g 标签。
HTML 复制代码
    <!-- 
          展示层
          * 整个画布的事件监听
        -->
<svg version="1.1" ref="svgElement" xmlns="http://www.w3.org/2000/svg" transform-origin="0 0"
      style="min-width: 100%; min-height: 100%;background-color: white;cursor:move" @click="handleClickOut"
      @mousedown="handleMousedown" @mouseup="handleMouseupOut" @mousemove="handleMousemove"
      @dragover="handleDragOver" @drop.stop="handleDrop"
       @mouseleave="handleMouseupOut" @wheel="handleWheel">
      <g ref="rootGroup"
      :transform="`matrix(${scale}, 0, 0, ${scale}, ${transform.x}, ${transform.y})`">
        <DiagramShape v-bind="props" />
      </g>
  </svg>
TS 复制代码
// 鼠标按下事件
function handleMousedown(event: MouseEvent) {
  if (!svgElement.value) return;
  isDragging.value = true;
  const CTM = svgElement.value.getScreenCTM();
  if (!CTM) return ;
  startPos.value = {
    x: (event.clientX - CTM.e) / CTM.a,
    y: (event.clientY - CTM.f) / CTM.d,
  };
}

// 鼠标移动事件
function handleMousemove(event:MouseEvent) {
  if (!isDragging.value) return;
  if (!svgElement.value) return;
  const CTM = svgElement.value.getScreenCTM();
  if (!CTM) return ;
  const dx = (event.clientX - startPos.value.x * CTM.a - CTM.e) / CTM.a;
  const dy = (event.clientY - startPos.value.y * CTM.d - CTM.f) / CTM.d;

  transform.value.x += dx;
  transform.value.y += dy;

  startPos.value = {
    x: (event.clientX - CTM.e) / CTM.a,
    y: (event.clientY - CTM.f) / CTM.d,
  };
}

// 鼠标滚轮事件
function handleWheel(event:WheelEvent) {
  if (!svgElement.value) return;
  event.preventDefault();

  const CTM = svgElement.value.getScreenCTM();
  if (!CTM) return ;
  const delta = event.deltaY < 0 ? 1.02 : 0.98;
  scale.value *= delta;
}

svgElement.getScreenCTM()

在 SVG 中,getScreenCTM 方法返回的是一个变换矩阵(DOMMatrix 或 SVGMatrix),表示从 SVG 元素的用户坐标系到屏幕坐标系的变换关系。

css 复制代码
| a  c  e |
| b  d  f |
| 0  0  1 |

其中,a、b、c、d、e 和 f 是矩阵的元素,具体含义如下:

  1. a 和 d:缩放和旋转 a:表示 X 轴方向的缩放和旋转。 d:表示 Y 轴方向的缩放和旋转。 如果 a 和 d 都是 1,表示没有缩放和旋转。如果它们是其他值,表示有缩放或旋转: 缩放:如果 a 和 d 都是相同的值(如 2),表示在 X 和 Y 方向都有相同的缩放比例。 旋转:如果 a 和 d 不相等,或者 b 和 c 不为 0,表示有旋转。
  2. b 和 c:旋转 b:表示 Y 轴方向的剪切(shear)或旋转。 c:表示 X 轴方向的剪切(shear)或旋转。 在没有旋转的情况下,b 和 c 都是 0。如果它们不为 0,表示有旋转或剪切。
  3. e 和 f:平移 e:表示 X 方向的平移。 f:表示 Y 方向的平移。

节点位置计算

初始化时会有一个根节点。当我们添加一个子节点时候,需要计算子节点的位置。伪代码步骤如下:

  1. 创建新节点。
  2. 将新创建的节点添加到当前节点的关系中。
  3. 重新计算当前子节点的位置,保持当前节点和所有子节点的中心对齐。
ts 复制代码
// 获取MindMap节点的配置,创建节点
const shapeOption = shapeFactory.getModelShapeOption("MindMap");
const createShape = shapeFactory.fromOption(shapeOption, projectId);
// 将子节点添加到父节点中关系中
mindMapManager.addRetrospectOption(sourceShape, createShape.id);
// 重新计算 sourceShape 的子节点位置
const updateShapes = mindMapManager.calcTreePosition(sourceShape, shapeMap);
if (updateShapes.size > 0) {
  // 更新节点
  save()
}

需要保持节点之间的间隙是相同的。然后右边整体的高度 y 中心点和父节点的 y 中心点是等高的。

将上面的图抽象下效果如下。

js 复制代码
const sourceMidY = sourceShape.absY + sourceShape.bounds.height / 2;
const allHeight = height1 + height2 + height3 + gap * 2;
第一个子节点的 y 坐标 =  sourceMidY - (allHeight) / 2;
第二个子节点的 y 坐标 = sourceMidY - (allHeight) / 2 + height1 + gap;
// ...依次类推
ts 复制代码
async function calcTreePosition(
  sourceShape: ShapeEntity,
  shapeMap: Map<string, ShapeEntity>
) {
   // 计算所有子节点高度
   const childShapes = shapeFactory.getChildShapes(sourceShape);
   const shapeHeight = childShapes.reduce((acc, shape) => {
     return acc + shape.bounds.height;
   }, 0);
   // 固定每个节点直接间隔
   const verticalGap = 50;
   const horizontalGap = 120;
   const totalHeight = totalHeight + (childShapes.length - 1) * verticalGap;
   const startY = sourceShape.bounds.absY + totalHeight / 2;
    //  第一个子节点的位置
   const x = sourceShape.bounds.absX + sourceShape.bounds.width + horizontalGap;
   const y = startY - totalHeight / 2;
   childShapes[0].bounds.absX = x;
   childShapes[0].bounds.absY = y;
   for(let i =1; i < childShapes.length; i++) {
     const prevShape = childShapes[i-1];
     // x 位置都相同
     shape.bounds.absX = x;
    // 距离第一个节点的起点的距离
     shape.bounds.absY = prevShape.bounds.absY + prevShape.bounds.height + i * verticalGap ;
   }
}

节点连线

当我们创建了子节点以后,就可以去实现节点之间的连线了。其实就是两个节点通过三次贝塞尔曲线连接。

三次贝塞尔曲线由四个点定义:两个端点(起点和终点)和两个控制点。通过调整控制点的位置,可以改变曲线的形状,使其具有高度的灵活性和可控性。

其中起点和终点是节点上的两个点,控制点是可以按照实际情况调整。

ts 复制代码
  function drawThirdOrderBezier(point1: {x: number, y: number}, point2: {x: number, y: number}) {
     // 计算两个点之间的中心点
     const centerX = (point1.x + point2.x) / 2;
     // 调整控制点的位置,使曲线更平滑
     const control1X = centerX + (point2.x - point1.x) * 0.25; // 控制点1向右偏移
     const control1Y = point1.y;
     const control2X = centerX - (point2.x - point1.x) * 0.25; // 控制点2向左偏移
     const control2Y = point2.y;
 
     // 生成贝塞尔曲线路径
     const linePath = `M ${point1.x} ${point1.y} C ${control1X} ${control1Y}, ${control2X} ${control2Y}, ${point2.x} ${point2.y}`;
 
     return { linePath };
  }

组件中调用的地方

TS 复制代码
const pathArray = computed(() => {
    const pathArr: {
        linePath: string, // 曲线
        color: string
    }[] = [];
    const shapeDepth = props.shape.style.retrospectOption?.shapeDepth;
    const sourceBounds = props.shape.bounds;
    // 关联的下一级图形
    props.shape.style.retrospectOption?.relationTypes?.forEach(
        (item) => {
            const shapes = props.graph.symbols;
            let targetShape = shapes?.find(
                (one) =>
                    shapeDepth &&
                    one.style.retrospectOption?.shapeDepth ===
                    shapeDepth + 1 && one.id === item.shapeId
            );
            if (!targetShape) {
                return;
            }
            const targetBounds = targetShape.bounds;

            // 计算矩形1的右侧重点和矩形2的左侧中点
            const point1 = {
                x: sourceBounds.absX + sourceBounds.width ,
                y: sourceBounds.absY + sourceBounds.height / 2
            };

            const point2 = {
                x: targetBounds.absX,
                y: targetBounds.absY + targetBounds.height / 2
            };
            /** *
                 * 根据两个点的位置去绘制一个曲线 linePath
                 */
            // 颜色
            let color = "black";
            const bezierRes = curve.drawThirdOrderBezier(point1, point2);
            const { linePath } = bezierRes;

            pathArr.push({
                linePath: linePath, // 曲线
                color: color
            });
        }
    );
    return pathArr;
});

文字编辑

思路:当我们双击编辑图形时,在对应位置渲染一个编辑框。

  1. 双击节点后在节点 nameBounds 处绘制 textarea 输入框。
  2. 当输入完毕以后需要去更新节点的文本内容。
  3. 失去焦点时,隐藏 textarea 输入框。
  4. 有一点需要注意,当文案特别多的时候,需要更新当前节点的 bounds。如果当前节点的高度发生,还需要更新与子节点对齐的位置。
HTML 复制代码
  <g>
    <foreignObject
      :width="previewBounds.width"
      :height="previewBounds.height"
      :x="previewBounds.absX"
      :y="previewBounds.absY"

    >
      <textarea
        ref="textarea"
        :value="editorModel.text"
        class="v-label-Editor"
        style="width:100%;height:100%;background: transparent;word-break: break-all;"
        :spellcheck="false"
        :style="{
          fontSize:editorModel.style.fontSize+'px',
          fontWeight:editorModel.style.fontWeight,
          background:editorModel.style.background,
          lineHeight: 1.5,
        }"
        @input="handleInput"
        @blur="handleSave"
      />
    </foreignObject>
  </g>

总结

通过 SVG 和相关逻辑计算,我们可以实现一个简易的思维导图。支持动态添加、删除和编辑节点,节点之间的关系通过贝塞尔曲线展示。此外,通过拖动和缩放操作,用户可以方便地浏览和编辑思维导图。近期会更新更多关于 SVG 相关的内容。有兴趣的话点击关注不会错过哦!

相关推荐
用户98402276679184 天前
【React.js】渐变环形进度条
前端·react.js·svg
明远湖之鱼9 天前
opentype.js 使用与文字渲染
前端·svg·字体
wsWmsw11 天前
[译] 浏览器里的 Liquid Glass:利用 CSS 和 SVG 实现折射
前端·css·svg
CodeCraft Studio11 天前
CAD文件处理控件Aspose.CAD教程:在 Python 中将 SVG 转换为 PDF
开发语言·python·pdf·svg·cad·aspose·aspose.cad
红烧code22 天前
【Rust GUI开发入门】编写一个本地音乐播放器(4. 绘制按钮组件)
rust·gui·svg·slint
吃饺子不吃馅23 天前
AntV X6图编辑器如何实现切换主题
前端·svg·图形学
吃饺子不吃馅25 天前
深感一事无成,还是踏踏实实做点东西吧
前端·svg·图形学
吃饺子不吃馅1 个月前
AntV X6 核心插件帮你飞速创建画布
前端·css·svg
吃饺子不吃馅1 个月前
揭秘 X6 核心概念:Graph、Node、Edge 与 View
前端·javascript·svg
吃饺子不吃馅1 个月前
如何让AntV X6 的连线“动”起来:实现流动效果?
前端·css·svg