实现思维导图思路
- 思维导图是通过根节点分叉出多个子节点,每个子节点又可以分叉出更多的子节点,形成一个树状结构。
- 使用 Svg 实现时,可以通过
rect
来绘制节点. - 通过节点的
relationTypes
属性来表示节点之间的关系。计算出节点和节点之间的连线。可以使用三次贝塞尔曲线来实现。 - 当我们添加、删除节点时,需要重新计算关联节点的相关信息(显示、位置、关系线)等。例如删除父节点的时候,需要删除父节点的子节点。
- 双击节点可以进入编辑状态。输入完更新节点文本信息。
通过上面的思路,我们定义了一个节点 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
}
代码实现
展开和收起
- 当节点下有子节点时,我们可以点击收起 icon 将子节点 isDelete 设置为 true。
- 更新 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>"
- 记录鼠标点击的位置。
- 记录拖动鼠标的位置。
- 更新鼠标的偏移量。
- 滚动鼠标记录 scale 缩放值。
- 使用 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 是矩阵的元素,具体含义如下:
- a 和 d:缩放和旋转 a:表示 X 轴方向的缩放和旋转。 d:表示 Y 轴方向的缩放和旋转。 如果 a 和 d 都是 1,表示没有缩放和旋转。如果它们是其他值,表示有缩放或旋转: 缩放:如果 a 和 d 都是相同的值(如 2),表示在 X 和 Y 方向都有相同的缩放比例。 旋转:如果 a 和 d 不相等,或者 b 和 c 不为 0,表示有旋转。
- b 和 c:旋转 b:表示 Y 轴方向的剪切(shear)或旋转。 c:表示 X 轴方向的剪切(shear)或旋转。 在没有旋转的情况下,b 和 c 都是 0。如果它们不为 0,表示有旋转或剪切。
- e 和 f:平移 e:表示 X 方向的平移。 f:表示 Y 方向的平移。
节点位置计算
初始化时会有一个根节点。当我们添加一个子节点时候,需要计算子节点的位置。伪代码步骤如下:
- 创建新节点。
- 将新创建的节点添加到当前节点的关系中。
- 重新计算当前子节点的位置,保持当前节点和所有子节点的中心对齐。
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;
});
文字编辑
思路:当我们双击编辑图形时,在对应位置渲染一个编辑框。
- 双击节点后在节点
nameBounds
处绘制 textarea 输入框。 - 当输入完毕以后需要去更新节点的文本内容。
- 失去焦点时,隐藏 textarea 输入框。
- 有一点需要注意,当文案特别多的时候,需要更新当前节点的 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 相关的内容。有兴趣的话点击关注不会错过哦!