在这里插入图片描述
在这里插入图片描述

实例:思维导图(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 间距)。

② 本节点 X40 + 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:视觉暗示"这里可以添加子节点"(点击叶子 → ➕ 按钮生效)。结构状态的可视化引导——没有子节点的节点可扩展。


十、下篇预告

下一篇《触摸交互与节点操作》将实现:触摸命中检测(递归 + 矩形判断)、选中反馈、为叶子添加子节点(布局重排)、重置种子树。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐