博客 React Flow实战:从零到一构建复杂拓扑关系图,解决节点连线几何计算难题!

React Flow实战:从零到一构建复杂拓扑关系图,解决节点连线几何计算难题!

   数栈君   发表于 2025-11-26 17:57  1780  0

当前内容背景是源于海的运维项目中需要使用到关系拓扑图描述各个模块或资产间的关系,最初设计是要参考腾讯蓝鲸的拓扑图去做,在霁明做的流程图组件调研的基础上,最后决定使用React Flow实现该功能。

本文旨在说明实现这个功能的过程中用到的一些基础几何知识,使用的React Flow版本为V12。

最开始参考的拓扑图如下:

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/3a180f369654bb703c1898190323a639..png

构建基础流程图

参照React Flow官方文档很容易就能构建一个横向或者纵向的关系图

<ReactFlow nodes={nodes} edges={edges} fitView></ReactFlow>

这种方法构建出来的关系图必须定义节点的位置,因此官方文档中提供了几种方案用于构建节点布局,这里由于我们只是需要初始化的时候生成一个树状有层级的布局图,所以只需要采用文档中最简单的dagre即可,如何使用dagre生成一个树状的布局在文档中也有相应的示例,直接使用示例然后根据自己需要调整节点间间距即可,最终生成如下所示图,这种基础示例根据文档添加连线、点击等事件就可以实现一些基础的交互功能。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/8b0530e6e3f8d461d51848f7d37cd658..png

这个时候已经实现了最基础的一个关系图,但是想要实现示例图一样的效果有多个问题需要解决

  • 节点样式
  • 连接线带有箭头
  • 两个节点可以同时互为源和目标节点
  • 节点间连线起止点在节点边缘不受句柄位置限制

因为ReactFlow是基于DOM渲染,节点是DOM元素,所以改变节点样式是很简单的,只需要根据自己需要调整节点样式即可,

连接线带箭头对于ReactFlow来说也有相应的配置是否显示开始和结束的箭头,

互为源节点和目标节点在ReactFlow中默认节点自带源句柄和目标句柄,只是这两个句柄位置排列问题再加上节点位置也可以改变,所以这种固定位置的句柄无法适应更多变的关系图谱,因此实际要解决的问题在于第四点。

自定义节点

要解决连线不受句柄固定位置限制我们首先要知道,在ReactFlow中连接线的起始点和结束点都是在连接句柄的中心点,解决这个问题有两种方法:

  • 实时计算节点间的最近位置,然后将对应句柄定位到计算的位置,再让句柄不可见
  • 设置源句柄和目标句柄样式,将句柄放大到和节点一样大小,然后让节点覆盖住句柄,再然后去计算连接线和节点边的交点,设置连接线起止位置为交点位置

第一种方法需要实现的话节点上有几个连接线就会存在几个句柄,每个节点还需要多出两个闲置的句柄用于手动连接节点,并且在连接的时候是不好计算的,而且句柄是可以控制节点是否能连接,支持几个节点连接的,如果存在多个源句柄和目标句柄,这部分功能逻辑判断就会比较复杂。

第二种方法每个节点都只会有两个句柄,分别为源和目标。

本文中采用的是第二种方式,要实现该方式就需要我们去自定义节点,自定义节点也很简单,ReactFlow提供了自定义节点的参数入口,自定义节点如下:

export type NodeTypes = Record<string, ComponentType<NodeProps & {    data: any;    type: any;}>>;const nodeTypes = {    directional: useCallback((nodeProps: NodeProps) => {        return <DirectionalNode             {...nodeProps}             readonly={readonly}            onNodeHide={onNodeHide}             onAddNode={onAddNode}             showAddNode={showAddNode}            showHideNode={showHideNode}            showAddEdge={showAddEdge}        />    }, [readonly, showAddNode, showHideNode, showAddEdge, props.enableNodes]),    ...(ret.nodeTypes || {})};<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView></ReactFlow>

DirectionalNode为自定义tsx组件,在这里可以自定义自己需要的节点组件,同时定义两个句柄Handle,也可以在这里实现节点自定义的功能,将所有节点数据nodes的type值改成directional。

自定义边

自定义节点之后就可以创建一个自定义边了,因为我们需要计算边的起始和结束位置,然后绘制边路径出来,自定义边的方式和自定义节点一样。

export type EdgeTypes = Record<string, ComponentType<EdgeProps & {    data: any;    type: any;}>>;const edgeTypes = {    directional: DirectionalEdge,    ...(ret.edgeTypes || {}),};<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView></ReactFlow>

DirectionalEdge也是一个tsx组件,我们可以直接使用ReactFlow提供的BaseEdge去绘制边的svg路径,然后将所有边数据edges的type值改成directional。

<React.Fragment>    <BaseEdge        {...ret}        path={path}        labelX={labelX}        labelY={labelY}        labelBgPadding={[4, 2.5]}        className={data?.disabled ? 'yc-custom-edge__mask' : ''}        markerStart={data?.markerStart ? `url(#arrow-start_${ret.id}_${unique})` : ret.markerStart}        markerEnd={data?.markerEnd ? `url(#arrow-end_${ret.id}_${unique})` : ret.markerEnd}    />    <defs>        <marker className="react-flow__arrowhead" id={`arrow-start_${ret.id}_${unique}`} markerWidth="16" markerHeight="16" viewBox="-10 -10 20 20" markerUnits="strokeWidth" orient="auto-start-reverse" refX="0" refY="0">            <polyline stroke-linecap="round" stroke-linejoin="round" points="-5,-4 0,0 -5,4 -5,-4"></polyline>
        </marker>
        <marker className="react-flow__arrowhead" id={`arrow-end_${ret.id}_${unique}`} markerWidth="16" markerHeight="16" viewBox="-10 -10 20 20" markerUnits="strokeWidth" orient="auto-start-reverse" refX="0" refY="0">            <polyline stroke-linecap="round" stroke-linejoin="round" points="-5,-4 0,0 -5,4 -5,-4"></polyline>
        </marker>
    </defs>
</React.Fragment>

其中最主要的就是path的绘制,参考示例中使用的是直线,我们可以直接使用ReactFlow提供的getStraightPath方法获得,在这之前需要我们计算出两个边缘节点Ps和Pt的坐标。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/a8db8a409c914b6e3d4c27cb9b5240aa..png

这里我们就可以根据向量法去计算两个交点,Ps,P1,P2在同一条直线上,所以P1到Ps的距离和P1到P2的距离是线性的比例关系,distance是P1和P2的向量长度,也就是两点线段长度,t就是圆的半径和向量长度的比例因子,由此根据P1和比例可以计算出Ps的坐标。计算Pt的时候也是调用这个方法,只是将P2当作source节点,P1当作target节点。

function getNodeIntersectionCircle(intersectionNode, targetPosition) {    const { width: intersectionNodeWidth } = intersectionNode.measured;    const sourcePosition = intersectionNode.internals.positionAbsolute;
    const r = intersectionNodeWidth / 2;
      // P1    const x1 = sourcePosition.x + r;    const y1 = sourcePosition.y + r;    // P2    const { x: x2, y: y2 } = targetPosition;    // 计算向量分量    const dx = x1 - x2;    const dy = y1 - y2;    const distance = Math.sqrt(dx * dx + dy * dy);
    // 参数化计算交点    const t = r / distance;    return { x: x1 - dx * t, y: y1 - dy * t }}

至此我们就计算出了这条边和两个节点边缘的交点,使用getStraightPath即可获得svg的path以及边上文案的位置,实现后的效果如下图。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/4cbc9858214647af5d093b1d4d42c385..png

UI调整

按照示例开发完成后UI出了设计图,设计图和示例最大的区别在于节点和边的样式调整,调整后的设计图效果如下。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/ad50ac9cfca8cf1fd230bf9997e9ee08..png

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/6a6f28396cce5106d1b7d8efbd1d914e..jpg
计算矩形节点交点

根据上图,我们在自定义边计算的起始和结束坐标也需要调整,同时边的绘制也需要调整成曲线,我们这里还是按照上一步计算连接线的起始和结束坐标的思路去计算出连接线位于起止节点边缘的坐标。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/862f09b67bafca7afa2272d205d9e2ed..png

同样的将两个矩形分开计算两次,以求Ps为例,连接线只会和矩形的其中一条边相交,所以我们需要求得线段和矩形四条边的交点。

这里我们使用的是几何计算中求取两条线段的交点,要计算两条线段的交点,我们首先需要确定这两条线段是否相交,假设AB的端点为A(x1,y1)和B(x2,y2),,线段CD的端点为C(x3,y3)和D(x4,y4)

①线段方程,线段方程可以表示为

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/b408014374078b3ff1ab7d1f67850d90..png

②如果两条线段有交点,那么他们的交点坐标在两条线段上相同

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/71dd783af3f9299b182f1f46e35d29a6..png

③解这两个方程式可以计算出t和s的值

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/8a8e822fb546a6a3f234766267c9cbfb..png

如果存在交点,a和b的值都是在[0, 1]之间。

④计算交点

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/6edbddd7a02627a54c06b00dc0193ffd..png

这样我们就得到了连接线在节点边缘上的交点,也就是这条连接线的起始或者结束的交点,完成这步之后的效果如下:

function getNodeIntersectionRect(intersectionNode, targetPosition) {    const { width: intersectionNodeWidth, height: intersectionNodeHeight } =        intersectionNode.measured;    const intersectionNodePosition = intersectionNode.internals.positionAbsolute;    const width = intersectionNodeWidth;    const height = intersectionNodeHeight;
    const x1 = intersectionNodePosition.x + width / 2;    const y1 = intersectionNodePosition.y + height / 2;    const { x: x2, y: y2 } = targetPosition;    // 计算矩形边界(基于中心点)    const xmin = x1 - width / 2;    const xmax = x1 + width / 2;    const ymin = y1 - height / 2;    const ymax = y1 + height / 2;    let intersections = [];
      const line = [        [{ x: xmin, y: ymax }, { x: xmax, y: ymax }],        [{ x: xmax, y: ymax }, { x: xmax, y: ymin }],        [{ x: xmin, y: ymin }, { x: xmax, y: ymin }],        [{ x: xmin, y: ymin }, { x: xmin, y: ymax }],    ]    const getLinePoint = (p1: { x: number, y: number }, p2: { x: number, y: number }, p3: { x: number, y: number }, p4: { x: number, y: number }) => {        const m = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);        const a = ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / m;        const b = ((p3.x - p1.x) * (p2.y - p1.y) - (p3.y - p1.y) * (p2.x - p1.x)) / m;        if (a >= 0 && a <= 1 && b >= 0 && b <= 1) {            const x = p1.x + a * (p2.x - p1.x);            const y = p1.y + a * (p2.y - p1.y);            return { t: 0, x, y };        }
        return null    }    for (let index = 0; index < line.length; index++) {        const element = line[index];        const p = getLinePoint({ x: x1, y: y1 }, { x: x2, y: y2 }, element[0], element[1]);        if (p) intersections.push(p);    }
    // 选择最小正 t 对应的交点    intersections.sort((a, b) => a.t - b.t);    // 当t对应的交点不存在是,及两者重叠,设置为{ x: x1, y: y1 }    return intersections.length > 0 ? { x: intersections[0].x, y: intersections[0].y } : { x: x1, y: y1 };}
http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/94b9a967abfafe4a573c73c89f5f27d2..png

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/587045e712de1b0485f823d82476b4f2..jpg
计算圆角交点

完成上一步之后看起来似乎边的计算已经完成了,但是仔细看能发现,因为节点带有圆角,当连接线经过圆角的时候箭头离节点有一定距离,如果圆角不大的时候可能不太明显,但是如果圆角设置的较大且边框更明显的情况下偏离效果就会比较明显,这四个圆角相当于矩形四个角上和矩形两条边相切的一个圆,所以我们需要计算出连接线和四分之一圆的交点来替代掉和矩形边框的交点。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/e6b2859d5e3633ba1b7dbf0f926d0e36..png

依据上图我们首先需要去掉圆角之外的矩形边框,在计算连接线和矩形边框的交点的时候将四条边的坐标点设置为这几个切点,这样我们计算完成之后取到的

const line = [    [{ x: xmin + radius, y: ymax }, { x: xmax - radius, y: ymax }],    [{ x: xmax, y: ymax - radius }, { x: xmax, y: ymin + radius }],    [{ x: xmin + radius, y: ymin }, { x: xmax - radius, y: ymin }],    [{ x: xmin, y: ymin + radius }, { x: xmin, y: ymax - radius }],]

现在我们需要计算线段和圆的交点,这里使用圆的标准方程来计算(x-a)²+(y-b)²=R²,(x,y)就是交点坐标,(a,b)是圆心坐标。

根据线段方程我们可以得到计算交点的公式

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/9a7e10014ed64bab991a7d59a43043bb..png

将它代入到圆方程可以得到

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/c92edbeb140972caac2fe31e542c5963..png

然后将该方程式推导拆解:

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/1dedb0a82f63108feea65a1908965de2..png

如果 B²-4AC<0 说明没有交点。

如果 B²-4AC=0 说明是切线只有一个交点。

如果 B²-4AC>0 说明有两个交点。

而计算的t有两个值的情况下,如果这两个值都不在[0,1]范围内说明没有交点。

如果t只有一个值的情况则说明是相切,只有一个交点。

如果一个在[0,1]范围内,另一个不在[0,1]范围则说明线段一头在圆内,一头在圆外,只有一个交点。

如果两个值都在[0,1]范围内说明有两个交点。

如果两个t的值都在[0,1]范围内,只需要找到较小的那个t值,它对应的坐标点离P2就是最近的,也就是我们需要的坐标点。我们只需要用同样的方法计算四个圆,再找对最小t值对应的坐标点就是连接线的起始或者结束坐标点,最后效果如下:

// 计算与四个圆角的交点function checkCircle(cx, cy) {    let dx = x1 - x2;    let dy = y1 - y2;    let a = dx * dx + dy * dy;    let b = 2 * (dx * (x2 - cx) + dy * (y2 - cy));    let c = (x2 - cx) ** 2 + (y2 - cy) ** 2 - radius ** 2;    let discriminant = b * b - 4 * a * c;
    if (discriminant >= 0) {        let sqrtD = Math.sqrt(discriminant);        let t1 = (-b - sqrtD) / (2 * a);        let t2 = (-b + sqrtD) / (2 * a);        [t1, t2].forEach((t) => {            if (t >= 0 && t <= 1) {                let px = x2 + t * dx;                let py = y2 + t * dy;                intersections.push({ t, x: px, y: py, circle: true });            }        });    }}
checkCircle(xmin + radius, ymin + radius); // 左下角checkCircle(xmax - radius, ymin + radius); // 右下角checkCircle(xmin + radius, ymax - radius); // 左上角checkCircle(xmax - radius, ymax - radius); // 右上角
// 选择最小正 t 对应的交点intersections.sort((a, b) => a.t - b.t);

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/a2ea818601ba0b5bebbd9ced0d331ad6..png

最后我们需要将直线连接线换成弧线,我们需要用到二次贝塞尔曲线,因为开始和结束坐标我们已经计算出来了,只需要在线段中点偏移一个垂直方向的向量,计算出偏移后的坐标点就可以使用二次贝塞尔曲线绘制出弧线。

这里我们用到了垂直向量的计算方法去计算曲线的偏移坐标,向量是指具有方向和大小的量,它可以表示成带有箭头的线段,箭头方向就是向量方向,线段长度就是向量的大小。

而两个向量a和b互相垂直的条件是a*b=0,也就是x1x2+y1y2=0

export function optimizedLineToArc({ sourceX, sourceY, targetX, targetY }: GetSpecialPathParams, offset): [string, number, number] {    const x1 = sourceX, y1 = sourceY;    const x2 = targetX, y2 = targetY;
    // 中点计算    const mx = (x1 + x2) * 0.5;    const my = (y1 + y2) * 0.5;
    // 方向向量    const dx = x2 - x1;    const dy = y2 - y1;
   // 当dx dy为0时,直接返回x,y的值,连接直线   if(!mx || !my || !px || !py) {        return [`M ${x1} ${y1} L ${x2} ${y2}`, x1, y1]    }
    // 垂直向量    const len = Math.sqrt(dx * dx + dy * dy);    const px = -dy / len * offset;    const py = dx / len * offset;
    // 二次贝塞尔曲线(近似圆弧)    const centerX = mx + px;    const centerY = my + py;    const labelX = mx + px / 2;    const labelY = my + py / 2;    // console.log(offset, px, py)    // debugger    return [`M ${x1} ${y1} Q ${centerX} ${centerY} ${x2} ${y2}`, labelX, labelY]}
实际应用

综合运维管理系统实现了运维中心运维数据集成工作,基于综合运维管理系统实现运维数据的采集、清洗、分析,形成统一监控、告警、运维能力,保障数据中心的计算、存储、网络、软件等全要素资源安全稳定运行。

通过实时数据采集技术,支持探针和各种监控代理(如SNMP、IPMI、SSH等协议)在各个数据中心的服务器、存储设备、网络设备上部署上报硬件资源信息及其运行的关键指标数据和日志数据。然后将采集数据统一存储与整合,将从不同设备、不同数据中心采集到的数据统一传输至中央监控系统,可能涉及数据压缩、协议转换,确保数据的安全性和高效性。整体使用高可用架构,设计主备模式,确保监控系统本身具有高可用性。对于入库数据提供可视化分析、告警巡检分析的能力,提供直观的仪表板和资源拓扑图,展示各数据中心的实时状态,便于运维人员快速定位问题和决策。分析采集数据,根据预设阈值触发告警,支持告警策略的自定义和分组管理。

http://dtstack-static.oss-cn-hangzhou.aliyuncs.com/2021bbs/files_user1/article/4686f30c7b7239dc9b71d566bde5c528..png

0条评论
社区公告
  • 大数据领域最专业的产品&技术交流社区,专注于探讨与分享大数据领域有趣又火热的信息,专业又专注的数据人园地

最新活动更多
微信扫码获取数字化转型资料