用 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 相关的内容。有兴趣的话点击关注不会错过哦!

相关推荐
黄Java1 天前
SVG中linearGradient的id冲突的显隐问题深度解析
前端·svg
SuperHeroWu76 天前
【HarmonyOS Next】鸿蒙应用加载SVG文件显示图标
华为·svg·harmonyos·鸿蒙·加载·image·图标
engchina11 天前
React 项目中 SVG 图标的调试和预览方法
前端·javascript·react.js·svg
三翼鸟数字化技术团队25 天前
svg绘图知多少
svg
三天不学习1 个月前
深入解析SVG图片原理:从基础到高级应用
svg
大模型铲屎官1 个月前
HTML5 Canvas 与 SVG:让网页图形与动画活跃起来
前端·html·html5·svg·canvas·网页图形与动画
专注VB编程开发20年2 个月前
c#有什么显示矢量图SVG的控件VB.NET-svg转透明PNG图像
开发语言·c#·.net·svg·矢量图
小九九的爸爸2 个月前
浅谈ViewBox那些事(一)
前端·svg
前端大卫2 个月前
What?SVG 还能做动画,这么强大还不学!(附源码和Demo)
svg