外部管理树数据的增支与重应用
这个示例展示如何在 relation-graph 外部维护嵌套树数据:通过内联加号占位节点扩展分支,扁平化更新后的树,并在每次编辑后重新应用。它是基于应用侧数据管理的引导式分支创建参考,包含自动重布局、视口适配,以及共享画布设置和导出工具。
管理一棵树,并在每次分支编辑后重新应用它
这个示例构建了什么
这个示例构建了一棵从左到右展开的树:常规节点显示为紫色胶囊标签,每个分支末端都有一个圆形加号按钮。点击这些内联控件中的任意一个,会创建一到三个新的子节点,重新执行布局,并重新适配视口,使更新后的分支立即可见。
重点并不只是通用的节点插入。组件将自己维护的嵌套树作为唯一数据源,直接修改这棵树,然后在每次编辑后把扁平化结果重新回放给 relation-graph。一个悬浮辅助窗口补充了说明文本、画布设置和图片导出功能,但这个示例的核心是这套受控数据的重新应用循环。
数据是如何组织的
源数据以一个内联的嵌套 JsonNode 树 myTreeJsonData 开始。像 a、b、b1 和 c3 这样的真实内容节点,与 data.myType 被设为 'my-add-button' 的占位子节点并列存在。这些占位节点并不只是视觉装饰;它们本身就是交互模型的一部分,因为它们标记了新分支的插入位置。
组件把这份嵌套结构保存在 myTreeDataRef 中,因此应用自己拥有的树才是权威数据,而不是运行中的图实例对象。在每次渲染前,flattenTreeData() 会遍历这棵树,并把它转换成 relation-graph 所需的扁平 nodes 和 lines 数组。在这个过程中,每个扁平节点都会保留自己的 id、text 和 data,辅助逻辑还会标记源节点是否为叶子节点。
在重新应用扁平数据之前,还有一个额外的预处理步骤:每个新节点都会从 newNodeInitialXy 取得初始 x 和 y,而 newNodeInitialXy 会根据被点击的加号按钮更新。这意味着新分支会先从交互发生的位置出现,然后再由 doLayout() 将它们整理进从左到右的树布局中。
在真实产品中,同样的模式可以表示组织树、分类体系、引导式工作流构建器、决策树,或者任何需要让领域模型保持在图渲染器之外的层级结构。
relation-graph 是如何使用的
index.tsx 用 RGProvider 包裹这个 demo,而 MyGraph.tsx 使用 RGHooks.useGraphInstance() 作为主要集成入口。图配置了一个水平树布局,包含 layoutName: 'tree'、from: 'left' 和 treeNodeGapH: 100。节点外观通过透明填充和边框设置被隐藏,而 defaultNodeShape: RGNodeShape.rect、defaultLineShape: RGLineShape.StandardOrthogonal、defaultJunctionPoint: RGJunctionPoint.lr、defaultPolyLineRadius: 10 以及浅紫色的默认连线样式,共同定义了最终的几何形态和连接线风格。内置工具栏仍然保留在右下角。
实例 API 驱动了整个更新循环。组件先把自己管理的树扁平化,再通过 addNodes(...) 和 addLines(...) 应用结果,短暂等待后调用 doLayout(),最后执行 zoomToFit()。当某个分支增长时,它还会使用 generateNewNodeId(),以避免新插入的节点与现有 id 冲突。
自定义渲染通过 RGSlotOnNode 完成。这个插槽会检查 node.data.myType:占位节点渲染为可点击的加号按钮,而普通节点渲染为紫色圆角标签。这里没有自定义连线、画布或视口插槽。样式调整位于 my-relation-graph.scss 中,它为节点增加了浅紫色的选中光晕,并为连线和标签提供了对应的选中态覆盖样式。
悬浮辅助外壳来自共享的 DraggableWindow 组件。这个辅助窗口使用 RGHooks.useGraphStore() 读取当前的拖拽和滚轮模式,使用 setOptions() 在运行时切换它们,并通过 prepareForImageGeneration() 与 restoreAfterImageGeneration() 将图导出为图片。这些控件很实用,但它们属于共享的 demo 基础设施,而不是这个示例最有辨识度的技术点。
关键交互
- 点击圆形加号按钮,会让对应父节点的分支增长出一到三个自动生成的子节点。
- 每个新子节点都会带上自己的尾部 add-button 占位节点,因此这个分支可以继续通过同样的画布内交互方式增长。
- 每次修改后,组件都会重新应用扁平化后的树、重新执行布局,并让视口适配更新后的结构。
- 辅助窗口可以被拖动、最小化、展开成设置面板,并在运行时切换滚轮和拖拽行为。
- 同一个设置面板还可以把当前图视图导出为图片。
关键代码片段
这个片段表明,唯一数据源是一个嵌套的 JsonNode 树,其中混合了真实节点和占位式添加控件。
const myTreeJsonData: JsonNode = {
'id': 'a', 'text': 'a', children: [
{
'id': 'b', 'text': 'b', children: [
{
'id': 'b1', 'text': 'b1', children: [
{ 'id': 'b1-add-button', 'text': 'add node', data: { myType: 'my-add-button' } },
这个片段证明,组件会把受控树扁平化、写入插入初始坐标、重新应用完整结果,然后再重新布局图。
const flatJsonData = flattenTreeData(myTreeDataRef.current);
flatJsonData.nodes.forEach(newNode => {
newNode.x = newNodeInitialXy.current.x;
newNode.y = newNodeInitialXy.current.y;
});
graphInstance.addNodes(flatJsonData.nodes);
graphInstance.addLines(flatJsonData.lines);
await graphInstance.sleep(350);
await graphInstance.doLayout();
graphInstance.zoomToFit();
这个片段展示了辅助函数如何把嵌套树转换为 relation-graph 所期望的扁平节点数组和连线数组。
export function flattenTreeData(root: JsonNode) {
const nodes: any[] = [];
const lines: any[] = [];
const traverse = (node: JsonNode, parentId: string | null) => {
nodes.push({
id: node.id,
text: node.text,
data: { isLeaf: !node.children || node.children.length === 0, ...(node.data || {}) }
});
这个片段展示了受约束的编辑流程:在受控树中定位被点击的占位节点、生成新的 id、追加新的子节点、让加号按钮始终位于最后,并触发一次重新应用。
const treeNodeInfo = findNodeInMyTreeData(buttonNode.id);
if (!treeNodeInfo) return;
const nodeInTree = treeNodeInfo.parentNode;
const randomNewNodesNum = Math.ceil(Math.random() * 3);
for (let i = 0; i < randomNewNodesNum; i++) {
const randomId = graphInstance.generateNewNodeId();
const newNodeId = 'N-' + randomId;
nodeInTree.children.push({
id: newNodeId,
text: newNodeId,
children: [{ id: newNodeId + '-add-button', text: 'add node', data: { myType: 'my-add-button' } }]
});
}
这个片段说明 RGSlotOnNode 是编辑工作流的一部分,而不只是一个外观皮肤。
<RGSlotOnNode>
{({ node }) => {
return node.data && node.data.myType === 'my-add-button'
? (
<div
className="cursor-pointer h-8 w-8 text-purple-800 hover:bg-purple-700 hover:text-white rounded-full flex place-items-center justify-center"
onClick={() => addNodesForParent(node)}
>
<CirclePlus size={20} />
</div>
这个片段展示了共享的悬浮窗口如何通过图实例 API 改变画布行为并触发图片导出。
const graphInstance = RGHooks.useGraphInstance();
const { options } = RGHooks.useGraphStore();
const dragMode = options.dragEventAction;
const wheelMode = options.wheelEventAction;
const canvasDom = await graphInstance.prepareForImageGeneration();
const imageBlob = await domToImageByModernScreenshot(canvasDom, {
backgroundColor: graphBackgroundColor
});
await graphInstance.restoreAfterImageGeneration();
这个示例的独特之处
根据对比数据,这个示例的独特之处在于它把外部嵌套树作为权威模型,直接修改这棵树,再次将其扁平化,并在每次结构变化后重新应用结果。这不同于 expand-gradually 和 open-by-level 一类示例,后者主要是逐步展开已存在于已加载数据集中的分支,而不是创建全新的子节点。
相比 create-object-from-menu,这个示例要受约束得多。它同样会在运行时添加节点,但方式是通过在 RGSlotOnNode 中渲染的内联占位子节点来完成,而不是通过一个通用的右键 CRUD 工作流去执行更广泛的创建、连接和删除行为。相比 node-style4 和 node-style2,这里的自定义节点渲染首先服务于功能:这个插槽是用来驱动分支增长的,而不只是给静态树换一层皮肤。
其中一个尤其不寻常的细节是:组件会在重新布局之前,先用被点击的 add-button 坐标为每个扁平节点写入初始位置。再结合自动生成的 id、递归树查找、被保留在末尾的占位节点,以及每次编辑后自动适配全屏的行为,这使得这个 demo 成为一个聚焦于应用自管数据上引导式分支增长的参考实现,而不是静态查看器或完整图编辑器。
这个模式还适用于哪里
这种模式适用于那些允许用户扩展层级结构、但扩展方式必须受到控制的产品。例如允许新增团队分支的组织规划器、在批准父节点下追加组件的物料清单编辑器、原地添加分类的分类体系管理器,以及每一步都必须让领域模型在画布外保持权威的引导式决策树构建器。
当图视图只是更丰富应用状态的一种投影时,这种方式也特别有效。如果同一棵树还需要同时驱动表单、校验规则、持久化逻辑或审计历史,那么这种受控树方案可以让应用保持稳定的数据源,同时继续使用 relation-graph 来处理布局、节点渲染、视口控制和导出。