目的:
借助思维导图的互动操作,让⽤户更深⼊地参与到教学内容中。
要求:第二个层级的所有节点都要在第一个节点及其所有子节点之后。
调研
svg动画
展开动画
动画效果:
xml
<style>
.curve {
stroke: black;
stroke-width: 2;
fill: none;
stroke-dasharray: 0, 1000; /* 初始值设置为 0 */
animation: draw-curve 3s forwards; /* 使用 CSS 动画 */
}
@keyframes draw-curve {
to {
stroke-dasharray: 1000, 0; /* 动画结束时显示完整的路径 */
}
}
</style>
<svg width="600" height="400">
<!-- 定义一个曲线路径 -->
<path d="M0 395.3793C89.425 395.3793 89.425 133.8207 178.85 133.8207" class="curve" />
<!-- <path d="M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" class="curve" /> -->
</svg>
移动动画
动画效果: 这两个动画能够实现导图从根节点一层一层展开的动画效果。注:该动画无法在 ios 设备下播放,需要使用svg中的标签支持
xml
<style>
path {
fill: none;
stroke: blue;
stroke-width: 2;
/* 添加过渡效果,过渡时间为 1 秒 */
transition: d 1s ease-in-out;
}
/* 定义一个类,用于改变 path 的 d 属性 */
.changed {
d: path("M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80");
}
</style>
<svg width="200" height="200">
<path d="M10 10 C 20 10, 30 20, 40 40 S 60 60, 80 80" />
</svg>
<button onclick="changePath()">Change Path</button>
<script>
function changePath() {
const path = document.querySelector("path");
// 添加 changed 类,触发过渡效果
path.classList.add("changed");
}
</script>
echarts 中的 tree 布局
实现效果:
底层使用 d3.js 计算每个节点的位置,但是节点的渲染比较固定没办法进行扩展。
d3.js 中的tree布局
实现效果:
d3更加紧凑的布局,每个节点也是用svg元素实现的,很难控制拓展性,第一级的最后一个节点与第二级所有孩子的第一个节点的位置固定,无法特殊设置。
深度优先算法计算节点位置
算法思想:使用二叉树的深度优先算法,先计算第一级最深的一个自节点的所有位置,在遍历到最后第一集最后一个节点的位置后,把最后一级节点的y位置返回,之后以这个Y位置为基础遍历下一级的所有子节点位置,如果一级的孩子都便利完成则把该节点的Y设置为孩子节点的Y位置中间并把当前节点的Y位置设置为所有其孩子的最深的Y位置,最终可以拿到每个子节点的(x,y)位置,通过 transform 属性定位节点位置,然后每个节点间连线使用svg元素连线。
直线: 三次贝塞尔曲线:

ini
const dfs = (obj, parent, depth, start) => {
const node = {
name: obj.name,
data: obj,
x: start + x,
y: depth * y,
depth: depth,
parent: parent,
endX: 0,
children: obj.children,
};
if (!obj.children) {
node.endX = node.x;
return node;
}
let before = null;
node.children = obj.children.map((child, index) => {
const newNode = dfs(
child,
node,
depth + 1,
before ? before?.endX || start : start
);
before = newNode;
return newNode;
});
node.x = getChildMiddleHeight(node);
node.endX = node.children.at(-1).endX;
return node;
};
项目中使用
基本使用
采用 dfs 方式来设计思维导图,实际的节点使用dom来实现,每个节点的连线使用 svg path 来连接
导图展开与收起
- 展开的时候使用 animate 标签来处理,要求每级都是紧凑的布局,如果节点有下一级孩子,那么导图会整体放大
- 收起时 使用 animation: hides 1s linear forwards;
- 控制同一层级的所有连线同时开始,并同时连接完成,需要计算一个线条的长度,然后控制动画运动的长度
扣题&答题
- 扣题答题的节点要是使用svg实现,扩展性很差,这里需要用dom节点来替代。
- 一个节点有题还有正常的展示,并且需要保持答题的弹窗不能覆盖扣题节点,需要在点击节点的时候为容器增加问题弹窗的宽度,并把扣题节点移动到中间位置。
- 处理答题的方块飞入到扣题位置
从扣题导图去往大导图
- 在答题完成,会让思维导图每一级都收到跟节点,然后飞入到大导图的位置。
- 大导图是一个直接展开完全的导图。
从大导图前往细节图
- 在课节的大导图每个最后一级的节点都能去小导图的详情页,这里导图都是要完全展开。
缩/放问题
- 思维导图要求支持缩/放功能,并且在动画的时候禁用。
- 放大,在放大的时候导图会有部分被隐藏,因此最大为导图渲染完毕的场景。
- 缩小,缩小的时候要求能正常滚动,故而需要一个占位dom来撑起滚动。
ini
import React, { useEffect, useRef } from 'react';
/**
* 控制页面缩放的 hooks 目前如果放大会导致页面内容被隐藏,先处理了缩小的操作
*/
const useScale = ({
min = 1,
max = 1,
beforeCb,
handleMove,
}: {
min?: number;
max?: number;
beforeCb?: () => boolean;
handleMove?: (val: number) => void;
}) => {
const initialDistance = useRef<number>(0);
const scaleRef = useRef<number>(1);
const scaleDom = useRef<HTMLDivElement>(null);
const scaleDirection = useRef(0);
const hasBeforeFn = () => {
if (beforeCb) {
return beforeCb();
}
return true;
};
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
if (hasBeforeFn()) {
scaleDirection.current = 0;
const x1 = e.touches[0].clientX;
const y1 = e.touches[0].clientY;
const x2 = e.touches[1].clientX;
const y2 = e.touches[1].clientY;
initialDistance.current = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
if (scaleDom.current) {
const a = scaleDom.current.getAttribute('style') || '';
const [_, num] = a.match(/scale((.+?))/) || [];
if (num) {
scaleRef.current = Number(num);
}
}
}
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length === 2) {
if (hasBeforeFn()) {
const x1 = e.touches[0].clientX;
const y1 = e.touches[0].clientY;
const x2 = e.touches[1].clientX;
const y2 = e.touches[1].clientY;
const currentDistance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const scaleVal = currentDistance - initialDistance.current;
const newScale = scaleRef.current + scaleVal / 500;
scaleRef.current = Math.max(min, Math.min(newScale, max));
if (scaleDom.current) {
scaleDom.current.style.transform = `scale(${scaleRef.current})`;
}
if (scaleDirection.current === 0) {
scaleDirection.current = scaleVal > 0 ? 1 : -1;
handleMove?.(scaleDirection.current);
}
}
}
};
useEffect(() => {
document.addEventListener('touchstart', handleTouchStart, {
passive: false,
});
document.addEventListener('touchmove', handleTouchMove, { passive: false });
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
};
}, []);
return {
scaleDom,
};
};
export { useScale };
实现
扣题
效果:
问题:
-
ios 无法播放svg动画
- 使用 svg 的animation 标签实现
-
事件监听器
- 在页面销毁的时候都清除
-
扣题导图,每次答完题,根节点会被隐藏(在小导图完成后会回到大导图的位置,由于节点都是设置的id选择器,会有相同的id-dom元素影响)
- 为小导图的根节点增加额外的唯一属性。