JavaScript is required

指标汇总关系图编辑器

这个示例构建了一个紧凑图编辑器,面向数值节点与汇总节点:可创建节点和连线、内联编辑数字,并根据入向关系重算汇总值。它在同一张点状全屏画布上结合插槽节点渲染、引导式创作工具、可选树重布局,以及共享设置/导出窗口。

构建一个基于图的指标汇总编辑器

本示例实现的内容

这个示例构建了一个紧凑的 relation-graph 编辑器,用于处理指标或成本汇总。界面初始状态包含一个铺满全屏的点阵画布、一个紫色汇总节点、两个蓝色数值节点、一组橙色编辑工具栏,以及位于图上方的浮动辅助窗口。

用户可以将新的数值节点或汇总节点拖到画布上,点击线条工具把输入连接到汇总节点,双击数值和名称进行编辑,删除当前选中的节点或连线,切换到树形布局,并打开共享设置或导出面板。这个示例的重点在于,这张图不仅仅是一个图示:进入某个节点的连线定义了汇总总值该如何重新计算。

数据是如何组织的

初始数据以内联方式声明为一个 RGJsonData 对象,其中包含 nodeslines。每个初始节点都已经带有固定的 xy 坐标,以及存放在 data 中的自定义元数据,尤其是 myNodeTypenumberValue。这让图同时具备可视化和语义化特征:节点类型控制行为,而 numberValue 则是用于聚合的字段。

在执行 setJsonData(...) 之前,代码会对每一条初始连线做规范化处理,确保它们具备 id,将线条形状切换为 Curve7,并设置明确的拐点。运行时创建则使用第二层数据定义:工具栏中包含 value-nodesum-node 两种可复用的节点模板,以及一个用于汇总连线的线条模板。在真实系统中,同样的结构可以表示 KPI 汇总、预算拆解、项目成本模型、人员总量统计,或者任何上游数值输入会驱动派生汇总结果的依赖图。

relation-graph 的使用方式

RGProvider 包裹了整个示例,这样 relation-graph 的 hooks 就能在图组件、工具栏和浮动设置面板之间解析当前激活的图实例。在 MyGraph 中,RGHooks.useGraphInstance() 会通过 setJsonData(...) 加载初始数据集,重新计算汇总节点,然后调用 moveToCenter()zoomToFit() 让画面重新居中并适配视图。

图配置保持了一个简洁、面向编辑器的基础场景。图初始使用 fixed 布局,采用基于边框的连线连接点,使用 StandardCurve 绘制连线,并把内置工具栏移到右侧。后续有一个独立的工具栏操作,会通过 updateOptions(...)doLayout() 将其切换为自上而下的树形布局。

两个 relation-graph 插槽定义了自定义 UI。RGSlotOnNode 替换了默认节点主体,因此数值节点会渲染内联数字编辑器,汇总节点会显示计算后的总值,并且每个节点都会在卡片下方显示一个可编辑的名称标签。RGSlotOnView 则把橙色的 SummarizerToolbar 直接注入图视口,而不是依赖外部页面侧栏。

编辑流程依赖的是图实例 API,而不是外部有状态布局代码。工具栏通过 startCreatingNodePlot(...) 启动节点创建,使用 generateNewUUID() 生成 id,通过 addNodes(...) 插入新项,通过 startCreatingLinePlot(...) 启动连线创建,在调用 addLines(...) 之前校验目标节点,并通过 removeNode(...)removeLink(...) 删除当前选中项。RGHooks.useCreatingNode()RGHooks.useCreatingLine() 会暴露当前创建模式,使工具栏能够直观高亮当前激活的工具。

浮动的 DraggableWindow 是一个共享辅助组件,而不是这个示例独有的逻辑,但它仍然是完整工作流中的重要一环。它的设置面板通过 RGHooks.useGraphStore() 读取图状态,使用 setOptions(...) 修改滚轮和拖拽行为,并通过调用 prepareForImageGeneration()domToImageByModernScreenshot(...)restoreAfterImageGeneration() 导出当前画布。局部 SCSS 则完善了编辑器外观,包括点阵背景、放置在节点外部的名称标签,以及用于线条标签的更强烈的选中态样式。

关键交互

拖拽任一工具栏模板都会启动 relation-graph 的节点落点创建流程,并在拖拽结束的位置插入一个新的类型化节点。新节点会继承模板中的颜色、形状和 myNodeType,因此同一组工具栏既能控制可编辑的数值节点,也能控制计算型汇总节点。

点击线条工具会启动引导式连线创建。代码禁止把数值节点作为目标节点,因此新建连线会被视为进入汇总节点的输入,而不是任意连接。添加有效连线后,示例会立即重新执行聚合逻辑。

双击数值节点中的数字会打开一个内联输入框。提交编辑后,会通过 updateNodeData(...) 写回新的 numberValue,并重新计算所有汇总节点。双击名称标签使用的是同样的内联编辑模式,但它通过 updateNode(...) 更新 node.text,而不会触发数值重算。

节点和连线删除都具备选中感知。点击节点或连线会将其保存为当前选中项,工具栏中会出现对应的删除按钮,而点击画布背景则会同时清除本地选中状态和 relation-graph 的选中状态。除了编辑功能外,浮动辅助窗口还可以被拖动、切换为设置面板,并用于将当前图导出为图片。

关键代码片段

下面这个片段展示了初始图使用固定坐标定位,并且节点元数据已经携带了编辑器后续使用的类型和数值。

const myJsonData: RGJsonData = {
  rootId: 'my-root',
  nodes: [
    { id: 'my-root', text: '成本', color: 'rgba(214,103,239,0.59)', x: -50, y: -2, data: { myNodeType: 'sum-node', numberValue: '510000' }},
    { id: 'newNode-1', text: '材料成本', color: '#5da0f8', x: -167, y: -176, data: { myNodeType: 'value-node', numberValue: '10000' }},
    { id: 'newNode-3', text: '人力成本', color: '#5da0f8', x: 29, y: -176, data: { myNodeType: 'value-node', numberValue: '500000' }}
  ],
  lines: [

下面这个片段证明了汇总值是由图拓扑推导出来的:代码读取流入节点,并把计算结果写回节点数据。

const incomingNodes = graphInstance.getNodeIncomingNodes(node);

let sum = 0;
incomingNodes.forEach(inNode => {
  let val = 0;
  if (inNode.data?.myNodeType === 'sum-node') {
    val = analyzeNodesForSum(inNode, newPath);
  } else {
    val = parseFloat(inNode.data?.numberValue || '0');
  }
  sum += isNaN(val) ? 0 : val;
});
graphInstance.updateNodeData(node, { numberValue: sum.toFixed(3) });

下面这个片段展示了 RGSlotOnNode 如何把节点主体变成可编辑的属性界面,而不只是一个普通标签。

{node.data?.myNodeType === 'value-node' ? (
  <MyEditableProperty
    dataType="number"
    currentValue={node.data.numberValue}
    onChange={(val) => {
      graphInstance.updateNodeData(node, { numberValue: String(val) });
      reCalcSumNodeValue();
    }}
  />
) : (
  <div className="font-bold px-2">{node.data?.numberValue}</div>
)}

下面这个片段展示了引导式连线编辑规则:工具栏启动 relation-graph 的建线流程,拒绝无效目标,并在插入后重新计算。

graphInstance.startCreatingLinePlot(e.nativeEvent, {
  template: { ...lineTemplate },
  onCreateLine: (from, to, finalTemplate) => {
    if ('id' in to) {
      if (to.data?.myNodeType === 'value-node') {
        return alert('Value node cannot be a summary target!');
      }
      graphInstance.addLines([{ ...finalTemplate, from: (from as RGNode).id, to: (to as RGNode).id, text: 'Summarizes' }]);
      onLineCreated();
    }
  }
});

下面这个片段展示了浮动辅助窗口如何通过 relation-graph 实例 API 暴露运行时画布行为调整和图片导出能力。

const canvasDom = await graphInstance.prepareForImageGeneration();
let graphBackgroundColor = graphInstance.getOptions().backgroundColor;
if (!graphBackgroundColor || graphBackgroundColor === 'transparent') {
  graphBackgroundColor = '#ffffff';
}
const imageBlob = await domToImageByModernScreenshot(canvasDom, {
  backgroundColor: graphBackgroundColor
});
await graphInstance.restoreAfterImageGeneration();

这个示例的独特之处

对比数据表明,这个示例并不只是因为使用了插槽、固定坐标或浮动辅助窗口而显得独特。邻近示例如 drag-to-create-nodes-with-preset-stylescreate-object-from-menuuse-d3-layoutcanvas-selection,都已经覆盖了这部分实现空间中的某些要素。这里更少见的是围绕汇总模型构建的一整组编辑特性组合。

drag-to-create-nodes-with-preset-styles 相比,这里并不是一个通用的样式编辑画布。被创建出来的节点带有语义类型,允许建立的连接受到约束,并且成功的编辑会直接驱动总值重算。与 create-object-from-menu 相比,这里的工作流是持续可见且带引导性的,而不是菜单驱动的:节点创建、连线创建、重命名、数值编辑和删除都保留在同一个覆盖式界面中。与 use-d3-layoutcanvas-selection 相比,这里的重点从外部布局或选择机制,转向了由拓扑关系驱动的计算。

准备好的稀有性与对比记录指向同一个结论:当需求是一个边表示聚合关系的轻量图编辑器时,这个示例是一个很强的起点。它最不常见的组合是模板驱动的节点创建、带校验的连线编辑、基于插槽的内联属性编辑、递归式汇总重算、选中感知删除、可选重新布局,以及共享的画布设置或导出工具同屏出现。

这种模式还能应用到哪里

这种模式非常适合用于 KPI 汇总设计器、预算和成本拆解规划工具、收入分解工具以及运营记分卡编辑器。在这些场景中,用户需要建模上游数值如何流入一个或多个汇总节点,并立即验证最终得到的总值。

它同样适用于那些把图本身作为计算模型的依赖式规划工具,例如人员估算、组件成本分摊或分层目标规划。在不改变整体编辑结构的前提下,同样的基于插槽的编辑模式还可以扩展到每个节点更多字段、更严格的校验规则,或领域专用的节点模板。