FEATURE_TREE:何时不应强求统一抽象

「统一抽象」很容易显得正确:既然树也是图,何不和流程图共用一套 JSON、事件和工具栏?

但数学上的包含关系,并不意味着产品交互可以按同一种方式实现。用户新增子主题、折叠一整条分支、重新计算层级布局时,面对的是子树,而不是任意图的连线编辑。继续复用,通常只是把复杂度从两条清晰的路径挪进越来越多的条件分支。

先区分两种模型

普通流程图通常可以表示为一组扁平 cells:

1
2
3
type GraphData = {
cells: Array<NodeCell | EdgeCell>;
};

节点和边由 ID 引用连接,允许多入边、多出边、回路和跨层关系。数据加载通常就是 fromJSON。

特性树或思维导图更接近递归结构:

1
2
3
4
5
type TreeNode = {
id: string;
label: string;
children?: TreeNode[];
};

它有一个根、明确的父子关系和顺序;展开、折叠、添加子节点、重新布局也都是对子树操作。它最终当然能画成 nodes + edges,但这份扁平结果更接近渲染产物,不是编辑时最自然的领域模型。

独立路径承认差异

Graph 容器和基础框架仍可共享;从数据加载到渲染、事件处理,树图则走自己的路径:

1
2
普通图:Graph JSON → fromJSON → 通用事件
树图:MindMapData → tree layout → 递归创建 cells → 树专用事件

加载树图时,先把领域树交给布局器计算节点位置,再递归创建 X6 Node 和 Edge;节点的 visible、折叠标记等状态也在这条路径中处理。

实际加载的数据类型是 MindMapData。id、type、label、width、height 与 zIndex 是必填字段,children 承载递归关系。x、y、attrs 用来覆盖布局结果或保存样式,visible、eventTarget 与 numberOfCollapsed 则记录折叠状态。

1
2
3
4
5
6
7
8
9
10
type MindMapData = {
id: string;
type: string;
label: string;
width: number;
height: number;
zIndex: number;
children?: MindMapData[];
visible?: boolean;
};

不能只保留 id、label 和 children。renderIndented 会把尺寸交给 Hierarchy.mindmap,并把层级交给 X6;缺少这些字段,就无法按当前实现完成渲染。

这样,树语义集中在 children 上,不会散落成「某条边是否为父子边」「这个节点能否连接另一个节点」之类的通用图判断。

ID 规则服务于新增,不是树关系来源

当前 FEATURE_TREE 编辑路径不从平面 cells 反推父子关系。findItem 沿 children 递归查找节点及其父节点,removeNode 也从该父节点的 children 中删除目标;树的关系来源始终是递归数据。

新建子节点时,ID 以父节点 ID 加上连字符和当前子节点数加一生成:

1
2
3
4
5
6
7
8
9
10
const length = dataItem.children ? dataItem.children.length : 0;

const child: MindMapData = {
id: `${id}-${length + 1}`,
type: 'topic-branch-l1',
label: `L1 分支主题${length + 1}`,
width: 100,
height: 24,
zIndex: maxZIndex,
};

root-2-3 可以读作 root-2 的第三个新增孩子,数字后缀隐含同级创建顺序;但运行时的父子关系并不靠它确定。新增逻辑只取 children.length + 1,删除中间孩子后再新增,可能复用仍存在的后缀。持久化或导入数据若依赖 ID 唯一性,不能把这段编号逻辑当作无冲突的通用 ID 生成器。

若持久化格式只保存扁平 cells,才需要另行定义从 ID 或显式 parentId 恢复树的契约。当前 MindMapData 并没有 parentId 或 order 字段,不能把这类扩展模型误写成现有实现。

布局能力与项目能力要分清

树布局可交给专门库,例如 mindmap layout:它根据树结构、节点尺寸、方向和间距计算坐标。项目侧仍要负责:

  1. 传入领域树和布局约束;
  2. 遍历布局结果,创建 Node 与 Edge;
  3. 将领域属性映射为样式、工具和可见性;
  4. 批量替换当前 cells;
  5. 应用折叠/展开状态。

布局算法由第三方布局器提供;领域模型如何转成渲染产物,以及何时折叠、编辑和重渲染,仍是项目侧的职责。

折叠与删除节点

当前实现不会从领域树中删除后代。collapse:topic 递归把目标节点的全部后代设为 visible = false,再在父节点写入 eventTarget = 'collapse' 和 numberOfCollapsed。expand:topic 则递归恢复后代的 visible = true,并将父节点标记为 expand。

随后 renderIndented 会重新遍历整棵树,重新创建 Node 与 Edge,并以一次 graph.resetCells(cells) 替换画布中的 cells:

1
2
3
4
5
6
7
8
9
遍历树
├── 创建节点
├── 创建指向每个孩子的边
├── 孩子 visible=false → 节点和入边加入 collapseCells
└── 孩子 visible=true → 节点和入边加入 expandCells

resetCells(cells)
对 collapseCells 的视图添加 cell-collapse class
对 expandCells 的视图移除 cell-collapse class

cell-collapse 加在 Node 和 Edge 的 View 上,因此被折叠孩子及其入边都会隐藏,父节点则留在画布中显示折叠数量。领域节点的 id 保持不变,但 resetCells 会重建 X6 Cell;这里保留的不是原 Cell 实例。

选中态也没有专门的恢复或清除步骤:折叠、展开处理器没有调用 clearSelection,渲染函数也不保存 getSelectedCells() 的结果。resetCells 后选择插件对旧 Cell 的处理属于 X6 运行时行为,源码没有给出“选中态必然保留”或“必然清除”的契约。

折叠的撤销语义也没有单独收口。非只读图会注册 History,只有变更选项带 ignoreHistory 才会跳过入栈。collapse:topic 与 expand:topic 没有传入该选项,也没有用 startBatch / stopBatch 把重渲染包成一次事务;因此源码并不承诺一次折叠或展开对应一条可撤销记录,实际入栈粒度仍取决于 X6 History 对 resetCells 的运行时处理。

这两个事件也没有 readOnly 提前返回。与新增、删除、编辑节点不同,当前实现允许只读树响应折叠和展开。遍历、Cell 创建和可见性收集均为 O(n);布局器内部的复杂度属于依赖库能力,不能从适配层代码直接推断。

双路径的成本是真实的

独立树路径有明确成本:每次改动都要确认,它是否同样影响树图。

普通图的边工具栏、右键菜单和连线规则,就未必适用于树图。树图误用了这些能力,用户就会看到不符合树语义的编辑入口;运行时因此需要在浮层和插件层明确收紧树图能力。

同时,测试也要分开覆盖:

  • 普通图:连线、嵌入、复制粘贴、边编辑;
  • 树图:新增子节点、删除子树、展开折叠、属性反写、重复加载。

共用容器不代表共用行为。

什么时候该统一,什么时候该分叉

只有当两类模型的核心用户操作共享同一组不变量时,统一才有意义。核心操作、数据真相和布局方式都已经不同,继续统一的维护成本往往高过拆分。

维度 更适合统一 更适合分叉
数据模型 都是任意 nodes + edges 一方是递归树
编辑操作 都是连线、移动、删除 一方是子树增删、折叠展开
布局 手工定位为主 一方必须持续自动布局
UI 工具 工具语义相同 一方禁止边编辑或右键动作

为树图保留独立路径,是为了不把它的语义埋进普通图不断累积的偶然复杂度中。