JavaScript is required

投资与股东关系探查

这个示例渲染围绕焦点公司的关系探查图,图内可控制投资、股东和历史投资分支。它结合按类型的懒加载(通过追加 RGJsonData 片段)、布局后展开占位归一化、自定义节点插槽,以及用于画布设置和图片导出的共享悬浮面板。

通过懒加载分支展开的投资与股东关系探索器

这个示例构建了什么

这个示例围绕一家焦点公司构建了一个以阅读为主的企业关系探索器。画布一开始会显示一个根公司卡片,以及图内的三个分支控制项 InvestmentShare HolderHistorical Investment,因此用户无需离开图本身,就可以打开不同方向的关系分支。

在视觉上,最终效果是一棵从左到右展开的业务树,包含白色公司卡片、蓝色控制节点、蓝色根卡片、正交连接线,以及带图案的分析风格背景。用户可以展开公司节点或控制节点以加载更多分支,点击空白画布清除选中状态,拖动或最小化浮动辅助窗口,在设置面板中切换滚轮和拖拽行为,并将当前图导出为图片。

这个示例的主要看点并不只是懒加载,而是类型化的控制节点导航、渐进式图增长以及布局后的清理如何结合在一起,使上游和下游关系分支能够在同一棵树中以不同的阅读方式呈现。

数据是如何组织的

初始图数据在 initializeGraph() 中以内联方式作为一个 RGJsonData 对象进行初始化。这个种子数据声明了 rootId: 'root'、一个焦点公司节点,以及三个分支控制节点,这些控制节点通过 data.myType 的值来标识分支类别。它们的连线方向是有意区分的:股东和历史投资控制节点连接在根节点的父侧,而向外投资控制节点连接在子侧。

在第一次调用 setJsonData(...) 之前,种子数据已经通过在控制节点上设置 expanded: falsechildrenLoaded: false 来为展开行为做好准备。第一次加载完成后,代码会立即在 updateNodeStyles() 中执行第二步预处理:读取计算得到的 node.lot.level 值,把负层级的展开按钮移动到左侧,隐藏根节点的展开按钮,把正层级的展开按钮移动到右侧,然后再把根节点向左平移 100 像素,以平衡最终构图。

附加图数据来自 mock-data-api.ts 中的本地异步辅助函数。每个辅助函数返回的都是另一个 RGJsonData 片段,而不是完整替换数据集。投资、历史投资和股东加载器返回公司节点。展开普通公司节点时,则会返回两个新的控制节点 InvestmentShare Holder,因此可以沿着相同的类型化分支模式继续递归探索。

在真实系统中,这一结构同样可以表示来自不同后端接口的公司 id、股东实体、对外投资、历史投资记录以及关系类别。届时只需将这些 mock 加载器替换为真实 API 调用,同时保持相同的图数据契约即可。

relation-graph 是如何使用的

这个示例包裹在 RGProvider 中,MyGraph 通过 RGHooks.useGraphInstance() 获取实时图实例。图选项配置了一个 tree 布局,设置 from: 'left'、100px 水平间距、10px 垂直间距、矩形节点、正交连线、圆角折线拐点、左右连接点,以及在节点展开或折叠时自动重新布局。

图实例 API 驱动了初始化和运行时更新流程。loading()setJsonData(...)clearLoading()moveToCenter()zoomToFit() 用于准备首次视图。在展开阶段,addNodes(...)addLines(...)enableCanvasAnimation()setCanvasCenter(...)sleep(...)disableCanvasAnimation()doLayout() 会把每个异步片段转换为一个分阶段的追加、聚焦、重新布局流程。之后再通过 getNodes()getRootNode()updateNode(...) 在布局完成后规范展开按钮的位置。clearChecked() 则用于处理画布上的取消选中。

这个示例没有使用默认节点渲染。RGSlotOnNode 挂载了 NodeSlot,它依据节点数据渲染三种视觉角色:用于分支控制的蓝色文本按钮、用于焦点公司的蓝色实心根卡片,以及用于普通公司的白色圆角卡片。正因为如此,这张图才能在同一个画布中混合动作式导航节点和只读业务实体。

浮动辅助窗口是通过本地子组件 DraggableWindow 实现的,而不是通过示例专用的图逻辑实现。在这个共享组件内部,RGHooks.useGraphStore() 用于读取当前交互模式,setOptions(...) 用于在运行时更新滚轮和拖拽行为,导出流程则结合 prepareForImageGeneration()getOptions()restoreAfterImageGeneration() 以及 modern-screenshot 来完成。

样式最后通过 SCSS 覆盖进行完善。样式表应用了平铺画布背景,重设了展开按钮和选中状态的颜色,并定义了根节点、控制节点、普通节点以及未使用的 more-btn 节点变体的卡片样式。

关键交互

最主要的交互是懒加载分支探索。展开控制节点或公司节点时会触发 onNodeExpand,它首先检查 childrenLoaded,根据 node.data.myType 选择正确的加载器,追加返回的节点和连线,把画布中心移动到已展开节点附近,短暂等待渲染稳定,重新执行布局,并再次应用布局后的节点调整。

这个示例还把分支类别纳入了交互模型。用户不需要打开单独的面板来选择对外投资还是股东,而是直接点击图中的蓝色控制节点,然后依据该节点类型选择下一个数据片段。

画布点击也很重要,因为它会重置选中状态。当用户已经探索了多个分支并希望重新获得一个干净视图时,这一点很有帮助。节点点击虽然存在,但这里只是输出到控制台,因此并不是一个有实际用户价值的功能。

浮动辅助窗口增加了第二层交互。用户可以通过标题栏拖动窗口、将其最小化、打开设置面板、把滚轮行为切换为滚动、缩放或无操作,把画布拖拽行为切换为框选、移动或无操作,并将当前图下载为图片。

关键代码片段

这个片段将图定义为一棵从左到右的正交树,并启用了分支打开时的自动重新布局。

const graphOptions: RGOptions = {
    debug: false,
    defaultExpandHolderPosition: 'bottom',
    defaultNodeShape: RGNodeShape.rect,
    defaultNodeBorderWidth: 0,
    defaultLineShape: RGLineShape.StandardOrthogonal,
    defaultPolyLineRadius: 5,
    defaultJunctionPoint: RGJunctionPoint.lr,
    defaultNodeColor: '#ffffff',
    reLayoutWhenExpandedOrCollapsed: true,
    layout: {
        layoutName: 'tree',
        from: 'left',

这个片段说明初始数据集并不只是一个根公司,还会在图首次渲染前预置三个带类型的分支控制节点。

const myJsonData: RGJsonData = {
    rootId: 'root',
    nodes: [
        { id: 'root', text: 'Tian Technology Co., Ltd.', data: { myType: 'root' } },
        { id: 'root-invs', text: 'Investment', disablePointEvent: true, expandHolderPosition: 'left', expanded: false, data: { myType: 'investment-button', childrenLoaded: false } },
        { id: 'root-sh', text: 'Share Holder', disablePointEvent: true, expandHolderPosition: 'left', expanded: false, data: { myType: 'shareholder-button', childrenLoaded: false } },
        { id: 'root-history-invs', text: 'Historical Investment', expandHolderPosition: 'left', expanded: false, data: { myType: 'historical-investment-button', childrenLoaded: false } },
    ],
    lines: [
        { from: 'root', to: 'root-invs', showEndArrow: false },
        { from: 'root-sh', to: 'root', showEndArrow: false },
        { from: 'root-history-invs', to: 'root', showEndArrow: false },
    ]
};

这个片段证明,展开逻辑是按节点类型路由的,而不是统一走一个通用加载器。

const componyId = node.id;
const myType = node.data.myType;
let newNodeAndLines: RGJsonData;
if (myType === 'investment-button') {
    newNodeAndLines = await fetchMockData4GetCompanyInverstment(componyId);
} else if (myType === 'historical-investment-button'){
    newNodeAndLines = await fetchMockData4GetCompanyHistoricalInvestment(componyId);
} else if (myType === 'shareholder-button') {
    newNodeAndLines = await fetchMockData4GetCompanyShareHolder(componyId);
} else {
    newNodeAndLines = await fetchMockData4GetCompanyNodeChildren(componyId);
}

这个片段展示了新分支会被追加到当前活动图中,然后经过聚焦和重新布局的分阶段处理,而不是重建整个数据集。

newNodeAndLines.nodes.forEach((n: JsonNode) => {
    n.x = node.x;
    n.y = node.y;
});
graphInstance.addNodes(newNodeAndLines.nodes);
graphInstance.addLines(newNodeAndLines.lines);
graphInstance.clearLoading();
graphInstance.enableCanvasAnimation();
graphInstance.setCanvasCenter(node.x + node.el_W / 2, node.y);
await graphInstance.sleep(400);
graphInstance.disableCanvasAnimation();
await graphInstance.doLayout();

这个片段展示了布局完成后的清理逻辑,它会让负层级、根层级和正层级使用不同的展开按钮位置。

graphInstance.getNodes().forEach((node) => {
    if (!node.lot) return;
    if (node.lot.level < 0) {
        if (node.expandHolderPosition) {
            graphInstance.updateNode(node.id, {
                expandHolderPosition: 'left'
            });
        }
    } else if (node.lot.level === 0) {
        graphInstance.updateNode(node.id, {
            expandHolderPosition: 'hide'
        });
    } else {
        if (node.expandHolderPosition) {
            graphInstance.updateNode(node.id, {
                expandHolderPosition: 'right'
            });
        }
    }
});

这个片段展示了 RGSlotOnNode 如何把带类型的节点转换为同一张图中的不同视觉角色。

const myType = node.data.myType;
const buttonTypes = ['investment-button', 'shareholder-button', 'historical-investment-button'];
return (
  <>
    {buttonTypes.includes(myType) && (
      <div className="my-node my-button-node">
        {node.text}
      </div>
    )}
    {myType === 'root' && (
      <div className="my-node my-root">
        {node.text}
      </div>
    )}
    {!myType && (
      <div className="my-node">
        {node.text}
      </div>
    )}
  </>
);

这个片段展示了共享设置面板如何在不改变关系数据本身的前提下更新运行时画布行为。

<SettingRow
    label="Wheel Event:"
    options={[
        { label: 'Scroll', value: 'scroll' },
        { label: 'Zoom', value: 'zoom' },
        { label: 'None', value: 'none' },
    ]}
    value={wheelMode}
    onChange={(newValue: string) => { graphInstance.setOptions({ wheelEventAction: newValue }); }}
/>

这个示例有什么独特之处

对比数据表明,这个示例与 investmentshow-more-nodes-by-pageshow-more-nodes-frontindustry-chainexpand-gradually 最接近,但它的侧重点与它们各不相同。与 investment 相比,这个示例更强调围绕一家焦点公司的调查流程,而不是固定股权结构,并且会把股东探索和历史投资探索与对外投资区分开。与 show-more-* 系列示例相比,它的重点也不是分页或揭示预加载密度,而是使用由分支类型驱动的异步加载器,并让普通公司节点继续交接到新的控制节点,从而递归推进探索。

它最鲜明的独特点是类型化的控制节点工作流。蓝色的图内节点本身就是导航界面,node.data.myType 决定了展开后应加载投资、股东、历史投资还是普通公司子节点。与通用展开按钮相比,这种模式更少见,因为分支类别会直接在图中可见且可操作。

另一个不常见的特征是布局后的双向清理。整张图仍然保持为同一个从左到右的树布局,但布局后的逻辑会隐藏根节点的展开按钮,把负层级控制节点保留在左侧,并把正层级控制节点移动到右侧。这样一来,同一家焦点公司的不同关系方向就可以在不切换布局、也不重建查看器外壳的情况下共存。

对比文件还支持一个更窄的判断:与那些关注静态分类、简单显隐流程或分页溢出处理的相邻示例相比,这个示例更适合作为企业尽调式关系探索的起点。它的独特组合在于类型化控制节点导航、渐进式的 addNodes(...)addLines(...) 增长、重新布局前的聚焦居中动画,以及在带图案的分析风格画布上进行业务风格节点渲染。

这种模式还能用在哪里

这种模式很适合用于企业尽调界面、股东穿透工具、投资组合探索器,以及那些需要围绕单一焦点实体展示上游和下游关系类别的内部合规视图。

当下一次查询依赖于被展开节点的角色时,这一方法同样适用于其他图谱领域。示例包括供应商与分销商调查、账户归属追踪、法律实体关系审查,以及把实体卡片与图内动作节点混合在一起的依赖关系探索器。

如果产品后续需要接入真实后端数据,本地 mock 加载器可以替换为返回相同 RGJsonData 片段的 API 调用。分支路由、追加 API、slot 渲染、布局后归一化以及导出与设置外壳都可以保持不变。