思维导图树形数据与递归布局:鸿蒙 Canvas 之道


实例:思维导图(Mind Map)|技术:树形数据模型、递归布局算法、贝塞尔连线、触摸命中检测
一、为什么选思维导图
1.1 从"数据图表"到"结构图表"
前几篇的图表(23 基础图表、36 K 线)都是"数值可视化"——把数字映射成几何形状。思维导图是"结构可视化"——把树形关系(父子、层级)可视化。两类图表的差异:
| 维度 | 数值图表 | 思维导图 |
|---|---|---|
| 数据 | 数组(值序列) | 树(节点 + 子节点) |
| 布局 | 坐标轴映射 | 递归布局算法 |
| 连线 | 折线/柱 | 贝塞尔曲线 |
| 交互 | 命中单个点 | 命中节点 + 结构操作 |
思维导图的技术核心是递归:递归数据(树)、递归布局(位置计算)、递归绘制(节点与连线)、递归命中(点击检测)——一个主题贯穿全篇。
1.2 功能清单
| 编号 | 功能 | 技术要点 |
|---|---|---|
| 1 | 树形数据模型 | MindNode 递归结构 |
| 2 | 自动布局(同级垂直分布) | 递归计算位置 |
| 3 | 贝塞尔连线 | quadraticCurveTo |
| 4 | 触摸选中 | 矩形命中检测 |
| 5 | 添加子节点 | 选中叶子 + 追加 |
| 6 | 重置 | 重建种子树 |
二、树形数据模型
/** 思维导图节点 */
interface MindNode {
id: number;
text: string;
x: number; // 布局后的中心 X
y: number; // 布局后的中心 Y
children: MindNode[];
}
递归结构:节点包含 children(子节点数组),子节点又是 MindNode——树 = 节点 + 节点的节点。x/y 是布局后的位置(初始 0,layout 后填充)。
2.1 种子树
private buildSeedTree(): void {
const root: MindNode = {
id: 1, text: 'HarmonyOS 学习', x: 0, y: 0, children: [
{ id: 2, text: 'ArkTS 语言', x: 0, y: 0, children: [
{ id: 5, text: '装饰器', ... }, { id: 6, text: '状态管理', ... } ] },
{ id: 3, text: 'UI 组件', ... },
{ id: 4, text: '数据持久化', ... },
],
};
}
三层种子树:根(HarmonyOS 学习)→ 3 个子节点 → 每个子节点 2 个叶子。10 个节点演示层级与布局。
三、递归布局算法 ★核心算法
3.1 节点计数
private countNodes(n: MindNode): number {
let c = 1;
for (const child of n.children) {
c += this.countNodes(child);
}
return c;
}
子树节点总数:自身 + 所有子节点的节点数。布局需要它来确定"子树占多高"。
3.2 布局核心
private layoutNode(n: MindNode, level: number, yTop: number): number {
const myCount = this.countNodes(n);
const myHeight = myCount * (this.nodeH + this.siblingGap);
// 本节点垂直居中于子树
n.x = 40 + level * (this.nodeW + this.levelGap);
n.y = yTop + myHeight / 2 - this.nodeH / 2;
// 递归布局子节点
let cursorY = yTop;
for (const child of n.children) {
const childHeight = this.countNodes(child) * (this.nodeH + this.siblingGap);
this.layoutNode(child, level + 1, cursorY);
cursorY += childHeight;
}
return myHeight;
}
布局算法逐行拆解:
① 子树高度:myHeight = myCount × (nodeH + siblingGap)——该子树所有节点(含自身)在垂直方向占据的总高度(每个节点 36px + 16px 间距)。
② 本节点 X:40 + level × (nodeW + levelGap)——层级越深 X 越大(向右展开)。level 决定列。
③ 本节点 Y(垂直居中):yTop + myHeight/2 - nodeH/2——本节点位于子树高度的中点(视觉上"父节点在子树中央")。
④ 递归子节点:cursorY 从 yTop 开始,每布局一个子节点,cursorY 累加该子节点的高度——兄弟节点垂直排列、不重叠。
⑤ 返回值:本子树总高度,供父节点继续累加。
算法思想:"后序遍历式"布局——先算子树规模,再定位自己,最后逐子布局。每个节点"垂直居中于自己的子树",整棵树自动铺开不重叠。这是思维导图布局的基础范式。
四、贝塞尔连线 ★核心绘制
private drawConnections(n: MindNode): void {
for (const child of n.children) {
// 起点父右边缘,终点子左边缘
const x1 = n.x + this.nodeW / 2;
const y1 = n.y + this.nodeH / 2;
const x2 = child.x - this.nodeW / 2;
const y2 = child.y + this.nodeH / 2;
const cx = (x1 + x2) / 2;
this.ctx.strokeStyle = '#CBD5E1';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.moveTo(x1, y1);
this.ctx.quadraticCurveTo(cx, y1, cx, y2); // 水平段
this.ctx.quadraticCurveTo(cx, y2, x2, y2); // 垂直段
this.ctx.stroke();
this.drawConnections(child); // 递归连线
}
}
两段贝塞尔拼成"L 形平滑线":
- 第一段:从父右边缘 (x1,y1) 到中点 (cx, y2)——控制点 (cx, y1),水平方向平滑过渡;
- 第二段:从中点 (cx, y2) 到子左边缘 (x2, y2)——控制点 (cx, y2),垂直方向平滑过渡。
两段曲线在中点 (cx, y2) 汇合——整体呈现"从父向右、再向下到子"的圆角 L 形。quadraticCurveTo(控制点, 终点):一条二次贝塞尔,控制点决定弯曲方向。
递归:每个父节点连完自己的所有子节点后,递归连子的子——连线与节点同步递归。
五、节点绘制
private drawNode(n: MindNode): void {
const selected = n.id === this.selectedId;
// 节点背景
this.ctx.fillStyle = selected ? '#3B82F6' : '#FFFFFF';
this.ctx.fillRect(n.x - this.nodeW / 2, n.y - this.nodeH / 2, this.nodeW, this.nodeH);
this.ctx.strokeStyle = selected ? '#3B82F6' : '#E2E8F0';
this.ctx.strokeRect(n.x - this.nodeW / 2, n.y - this.nodeH / 2, this.nodeW, this.nodeH);
// 文本(居中)
this.ctx.font = '12px sans-serif';
this.ctx.fillStyle = selected ? '#FFFFFF' : '#334155';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(n.text, n.x, n.y);
this.ctx.textAlign = 'start'; // 复位
this.ctx.textBaseline = 'alphabetic'; // 复位
// 叶子标记(右下角圆点)
if (n.children.length === 0) {
this.ctx.fillStyle = '#94A3B8';
this.ctx.beginPath();
this.ctx.arc(n.x + this.nodeW / 2 - 5, n.y + this.nodeH / 2 - 5, 3, 0, Math.PI * 2);
this.ctx.fill();
}
for (const child of n.children) {
this.drawNode(child);
}
}
节点 = 圆角矩形 + 居中文本:
- 选中态:蓝底白字(selectedId 匹配);未选中白底深字——选中态可视化(与 30 难度胶囊同款高亮逻辑);
- 文本居中:
textAlign='center' + textBaseline='middle'让文本精确落在节点中心;画完必须复位(start/alphabetic)——Canvas 状态泄漏防护(36 的 setLineDash 同理); - 叶子标记:右下角 3px 灰点——叶子节点(无子节点)的视觉标识,也是"可添加子节点"的暗示。
六、绘制顺序
private redraw(): void {
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
this.ctx.fillStyle = '#F8FAFC';
this.ctx.fillRect(0, 0, this.canvasW, this.canvasH);
this.drawConnections(this.root); // ① 连线(底层)
this.drawNode(this.root); // ② 节点(上层)
}
先连线后节点:连线在节点下方(节点盖住线头),视觉干净。层级:背景 → 连线 → 节点。
七、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 树形数据 | MindNode 递归 children | 结构建模 |
| 节点计数 | countNodes 递归 | 子树规模 |
| 递归布局 | 垂直居中 + cursorY 累加 | 自动铺开不重叠 |
| 贝塞尔连线 | 两段 quadraticCurveTo | 平滑 L 形 |
| 递归绘制 | 连线/节点同步递归 | 结构与绘制一致 |
| 选中态 | 蓝底白字 | 交互反馈 |
| 状态复位 | textAlign/textBaseline | 防泄漏 |
| 叶子标记 | 右下角圆点 | 结构暗示 |
八、文章小结
本篇完成了思维导图的数据与布局层:树形数据模型(递归 children)、递归布局算法(垂直居中 + 光标累加)、两段贝塞尔连线、递归绘制。这套"递归"主题贯穿全部——数据递归、布局递归、绘制递归、命中递归(下一篇)。理解递归,就理解了思维导图的全部。
下一篇《触摸交互与节点操作》将实现:矩形命中检测、选中反馈、为叶子添加子节点、重置。
九、深度扩展
1. 水平布局(经典左右展开)
本实例是"左根右展"(root 在左,向右展开)。经典思维导图是中心发散(root 居中,左右分支)。改造:布局时 level 为负的分支向左,正的分支向右;Y 轴相同。布局方向是配置问题,算法骨架不变。
2. 节点拖拽
触摸节点 → Down 记录、Move 更新节点 x/y(局部重绘)、Up 结束——手动调整布局。注意拖拽后与自动布局冲突(可"锁定手动位置"或"拖拽后重排子树")。交互复杂度提升,本实例未实现,留作扩展。
3. 节点编辑
双击节点 → 弹出文本输入 → 更新 text 重绘。文本编辑是思维导图的刚需,实现 = hitTest + 输入弹窗 + 更新节点。
4. 缩放与平移
画布大时(百节点)需要视口控制:scale 变换(ctx.scale)+ 平移偏移。变换矩阵统一处理,绘制代码不变。
5. FAQ
Q1:为什么布局时父节点垂直居中于子树?
A:这是"树形美观"的经典准则——父节点在子树的中央,视觉上"统领"子树。若父在子树顶部,连线会偏斜不协调。
Q2:countNodes 每次布局都递归调用,性能如何?
A:10 个节点几乎无开销。百级节点也可接受。万级需记忆化(缓存子树大小)。教学直算,大数据优化。
Q3:两段贝塞尔为什么不用一段三次贝塞尔?
A:一段 quadratic 只能控制一个方向弯曲;两段拼接出"水平+垂直"的 L 形。三次贝塞尔(bezierCurveTo 两个控制点)也能实现,但两段二次曲线更直观。曲线分段控制方向。
Q4:选中态为什么用 id 比较而非引用?
A:id 是稳定标识(即使节点对象被重建,id 不变);引用比较脆弱(对象复制后失效)。id 是节点身份。
Q5:叶子节点的圆点是做什么的?
A:视觉暗示"这里可以添加子节点"(点击叶子 → ➕ 按钮生效)。结构状态的可视化引导——没有子节点的节点可扩展。
十、下篇预告
下一篇《触摸交互与节点操作》将实现:触摸命中检测(递归 + 矩形判断)、选中反馈、为叶子添加子节点(布局重排)、重置种子树。
更多推荐




所有评论(0)