从零实现一个数据血缘拓扑图

节选自笔者在团队内做的一次技术分享,介绍了2022年7月从零开发的第一版数据血缘拓扑图实现逻辑。特别说明,原文档为飞书文档,转成Markdown后,图例有所丢失。

背景

为什么选择自研拓扑图?

主要有两点原因:

  1. 业界缺少我们需要的拓扑图方案

常规的有向图基于节点,数据血缘拓扑图基于Tree,我们所要构建的图不局限于Graph with Asset Field Node,而希望和资产平台对齐,实现Graph with Asset PSM Tree。

  1. 缺少现成的布局算法

目前缺少成熟的类似层次布局算法(Dagre)、d3-hierarchy、力导向图布局算法(Force-directed graph)这样的布局算法,需要自行实现布局,计算各点坐标位置。

如何实现数据血缘拓扑图?

自研拓扑图需要解决的核心问题:

  1. 如何计算布局,生成节点位置坐标?
  2. 如何绘制边?
  3. 如何绘制节点?

如何计算布局

在绘制边之前,首先要进行布局计算,获取边的起始点和目标点的位置坐标。

主要有两种思路:

  1. 使用特定布局算法计算位置坐标,如层次布局算法、力导向图布局算法
  2. 交给浏览器布局,渲染后获取位置坐标

前期调研的可选方案都是基于这两种思路实现:

  1. Antv G6/X6、React Flow

使用 dagre 的层次布局算法,提前计算所有节点位置信息。

  1. @tiktok-woody/react-dag

使用 dagre 的层次布局算法计算节点位置,同时在节点挂载后将节点的ID和DOM引用保存到Store中,使得外层可以通过ID操作节点DOM,进而调整节点位置。

  1. Coral xGraph (DataLineageGraph)

节点的宽高固定,节点组的外间距固定,根据节点组的位置和节点的索引提前计算节点位置。滚动时基于节点组内滚动偏移量,重新计算节点位置。

本血缘拓扑图参考 Coral 的多列布局形式,因此无法使用 dagre 的层次布局算法,又因为需要支持自定义节点和做粗粒度节点展开,只能在节点渲染挂载后使用 getBoundingClientRect() 获取位置。滚动时可以综合原位置信息和滚动偏移量,或者重新调用 getBoundingClientRect() 获取最新位置。

如何同步布局

由于节点组内部支持垂直滚动,当用户滚动后需要同步最新的节点位置信息,重新绘制边。

  1. 上层被动接收下层节点在挂载后发布的宽高和位置数据(缺点:节点组滚动时,节点内部无法感知 scroll 事件)

  2. 上层主动获取下层节点的宽高和位置信息

    • a. 自底向上:下层节点增加 DOM ID,上层使用 document.getElementById() 获取节点 DOM(缺点:违背 React 设计原则)
    • b. 自底向上:下层节点增加 ref,借助 Context 传给上层,上层从 Context 中获取节点 DOM
    • c. 自顶向下:上层为所有下层节点统一创建 ref,通过 Context 分发给下层节点,上层从 ref 集合中获取节点 DOM

本血缘拓扑图采用方案2-b,布局变化后由上层主动调用DOM API获取下层节点的宽高和位置信息。

使用什么方案绘制边

获取起始点和目标点的位置坐标后便可以绘制边。

  1. 基于Canvas
    • API:lineTo()quadraticCurveTo()bezierCurveTo()
    • 代表:Coral xGraph (DataLineageGraph)、Antv G6
    • 优势:高性能
    • 劣势:只能通过Canvas API修改样式;事件监听只能绑定在Canvas容器上,需要手动分发事件。
  2. 基于SVG
    • SVG元素:<line><polyline><path>
    • 代表:@tiktok-woody/react-dag、Antv X6、React Flow
    • 优势:矢量,支持缩放;支持CSS和DOM事件
    • 劣势:节点数量到达一定量级后性能较差

本血缘拓扑图采用SVG方案,和Canvas相比,它支持定义边的鼠标事件,可以通过CSS控制样式。

注意:2022年7月数据血缘图的数据量最多只有几百个节点,边的数量大约是点的2.5倍;2023年3月,已经远远超出该数据量。

使用什么方案绘制节点

血缘拓扑图的节点绘制较为简单,采用 Semi Tree 实现 PSM -> Method -> Field 树,通过 renderFullLabel 属性接管树节点的渲染。

详细实现

组件设计

拓扑图节点、连线绘制层级

暂时无法在飞书文档外展示此内容

从下往上依次是:

  • 背景层:绘制网格背景
  • 连线层:绘制底层灰色连线
  • 节点层:绘制节点,有跨层级连线时,上层节点将遮盖住连线
  • 高亮连线层:绘制顶层蓝色连线,有跨层级连线时,连线将浮在节点上层

拓扑图数据源

图的数据源只需要包含构建节点和边的最基本字段。

interface Node<T> {
  id: string;
  data: T;
}

interface Edge<T> {
  source: string;
  target: string;
  data: T;
}

示例:

const data = {
  nodes: [
    {
      id: 'node1'
    },
    {
      id: 'node2'
    },
  ],
  edges: [
    {
      source: 'node1',
      target: 'node2'
    }
  ],
};

拓扑图组件结构

暂时无法在飞书文档外展示此内容

拓扑图组件 Props

interface Position {
  x: number;
  y: number;
}

interface Rect {
  width: number;
  height: number;
}

interface NodeProps {
  /** 节点唯一ID */
  id: string;
  /** 节点名称,可以同时存在于 data 字段中 */
  label?: string;
  /** 节点原点位置(左上角) */
  position?: Position;
  /** 节点宽高尺寸 */
  rect?: Rect;
  /** 节点数据 */
  data?: Record<string, unknown>;
  /** 子节点 */
  children?: Node[];
  /** 自定义节点渲染 */
  renderNode: (node: NodeProps) => RectNode;
  /** 节点点击回调 */
  onNodeClick: (node: NodeProps) => void;
  /** 节点展开回调 */
  onNodeExpand: (node: NodeProps) => void;
  /** 节点菜单选择回调  */
  onNodeContextMenuSelect?: (id: string, type: ContextMenuType) => void;
}

interface EdgeProps {
  /** 边唯一ID,默认使用 'source_target' 拼接 */
  id?: string;
  /** 起始点ID */
  source: string;
  /** 目标点ID */
  target: string;
  /** 边样式 */
  style?: CSSProperties;
}

interface ColumnProps {
  /** 上游/下游/终点 */
  type: 'upstream' | 'downstream' | 'endpoint';
  /** 层数,0表示当前节点/终点 */
  depth: number;
  /** 节点数据源 */
  nodes: NodeProps[];
  /** 自定义节点渲染 */
  renderNode: (node: NodeProps) => ReactNode;
  /** 节点点击回调 */
  onNodeClick: (node: NodeProps) => void;
  /** 节点展开回调 */
  onNodeExpand: (node: NodeProps) => void;
  /** 节点菜单选择回调  */
  onNodeContextMenuSelect?: (id: string, type: ContextMenuType) => void;
}

interface LineageGraphProps {
  /** 节点数据源 */
  nodes: NodeProps[];
  /** 边数据源 */
  edges: EdgeProps[];
  /** 主节点ID */
  primaryNodeId?: string;
  /** 展示层级 */
  level?: number;
  /** 自定义节点渲染 */
  renderNode: (node: NodeProps) => RectNode;
  /** 自定义遍渲染 */
  renderEdge: (edge: EdgeProps) => RectNode;
  /** 节点点击回调 */
  onNodeClick: (node: NodeProps) => void;
  /** 节点展开回调 */
  onNodeExpand: (node: NodeProps) => void;
  /** 节点菜单选择回调  */
  onNodeContextMenuSelect?: (id: string, type: ContextMenuType) => void;
}

LineageGraph JSX 示例代码:

<LayoutProvider>
  <EdgeProvider>
    <EdgeLayer edges={edges} />
    <HighlightedEdgeLayer edges={highlightedEdges} />
    <NodeLayer nodes={aggregatedNodes} />
  </EdgeProvider>
<LayoutProvider/>

其中

  • LayoutProvider 用于提供节点布局信息,如坐标位置、宽高、DOM引用
  • EdgeProvider 用于提供边的绘制信息,如是否已经绘制

坐标系换算

HTML、SVG、Canvas 都以页面的左上角为 (0, 0) 坐标点(原点),坐标以像素为单位,x 轴正方向是向右,y 轴正方向是向下。

由于 HTML 中 Event 事件、getBoundingClientRect() 获取的坐标位置始终相对于页面原点,当页面发生滚动后,获取的坐标并不能反映节点、连线在 SVG 容器中的真实位置,因此布局计算的全部坐标都需要相对 SVG 容器左上角原点做一定偏移。

interface Rect {
  width: number;
  height: number;
  x: number;
  y: number;
}

export const translate = (position?: DOMRect, origin = { x: 0, y: 0 }): Rect | undefined => {
  if (!position) {
    return undefined;
  } else {
    return {
      width: position.width,
      height: position.height,
      x: position.x - origin.x,
      y: position.y - origin.y,
    };
  }
};

// 起点原始Rect
const rect1 = ...
// 终点原始Rect
const rect2 = ...

// 起点坐标
const p1 = useMemo(() => translate(rect1, origin), [rect1, origin]);
// 终点坐标
const p2 = useMemo(() => translate(rect2, origin), [rect2, origin]);

分层

层级计算方案

以下分层方案按“主节点模式”说明。“起止点模式”层级计算方式和主节点模式类似,起始点记为0层,终止点记为Infinity无穷大层,将起点作为主节点计算层级。

方案一:按边的关系累加计算

主节点记为0层,以下游层级计算为例,和主节点有直接边关系的点,记为下游1层,和下游1层有直接边关系记为下游2层,依此类推。如果某节点已经赋过层级,有新的边关系时不再重新计算。

方案二:按节点和主节点的最短路径计算

基于Dijkstra算法(不允许有环)。

方案三:按节点和主节点的“最长路径”计算

无高效算法,有环的情况下无最长路径。

分层案例

案例1:3个节点

边关系:A -> B,A -> C,B -> C,其中A为主节点。

方案 示意图
按最短路径计算,B、C是下游1层。 暂时无法在飞书文档外展示此内容
按最长路径计算,B是下游1层,C是下游2层。 暂时无法在飞书文档外展示此内容
案例2:4个节点

边关系:A -> B,B -> C,C -> D,A-> D,其中A为主节点。

方案 示意图
按最短路径计算,B、D是下游1层,C是下游2层。 暂时无法在飞书文档外展示此内容
按最长路径计算,B是下游1层,C是下游2层,D是下游3层。 暂时无法在飞书文档外展示此内容
按边关系累加计算,情况1同最短路径;情况2,B、C、D是下游1层。(避免了图中出现从右到左的反向箭头) 暂时无法在飞书文档外展示此内容
案例3:5个节点

边关系:A -> B,B -> C,C -> D,A -> E,E -> D,其中A为主节点。

方案 示意图
按最短路径计算,B、E是下游1层,C、D是下游2层。 暂时无法在飞书文档外展示此内容
按最长路径计算,B、E是下游1层,C是下游2层,D是下游3层。 暂时无法在飞书文档外展示此内容
按边关系累加计算,结果同最短路径。 暂时无法在飞书文档外展示此内容

代码实现

import { Node, Edge, GraphDataSource } from '../types';

interface DepthResult {
  depth2ids: Record<number, string[]>;
  id2depth: Record<string, number>;
}

/**
 * 主节点模式血缘图分层计算(按边关系累加计算)
 */
export const calcSingleGraphDepths = (graph: GraphDataSource, primaryId: string): DepthResult => {
  const id2depth: Record<string, number> = { [primaryId]: 0 };
  const depth2ids: Record<number, string[]> = {};
  const { nodes, edges } = graph;

  // 已经进行过分层,直接使用已有的depth
  const depthCalculated = nodes.find((node: Node) => typeof node.data?.depth === 'number');
  if (depthCalculated) {
    nodes.forEach((node: Node) => {
      if (typeof node.data?.depth === 'number') {
        const depth = node.data.depth;
        if (depth2ids[depth]) {
          depth2ids[depth].push(node.id);
        } else {
          depth2ids[depth] = [node.id];
        }
      }
    });
  } else {
    // 保证主节点Tree上所有节点depth都为0
    let primaryIdRootId = primaryId;
    const primaryNode = nodes.find((node: Node) => node.id === primaryId);
    if (primaryNode?.data) {
      primaryIdRootId = primaryNode.data.rootId;
    }
    const primaryNodeList = nodes.filter((node: Node) => node?.data?.rootId === primaryIdRootId);
    primaryNodeList.forEach((node: Node) => {
      id2depth[node.id] = 0;
    });

    // 计算当前节点直接上下游的相对层级
    const calcRelativeDepth = (id: string, depth = 0) => {
      if (depth2ids[depth]) {
        depth2ids[depth].push(id);
      } else {
        depth2ids[depth] = [id];
      }
      
      // 查找直接上下游
      const keys = Object.keys(id2depth);
      const associatedEdges = edges.filter(({ source, target }: Edge) => {
        return (source === id && !keys.includes(target)) || (target === id && !keys.includes(source));
      });

      const associatedNodeDepthMap: Record<string, number> = {};

      // 计算当前节点上下游层级
      associatedEdges.forEach(({ source, target }: Edge) => {
        const associatedNodeId = source === id ? target : source;
        let associatedNodeDepth = source === id ? depth + 1 : depth - 1;
        if (associatedNodeDepth === 0) {
          // 当下级或上级同主节点相同的层级,将其往上或者下层放
          associatedNodeDepth = source === id ? depth - 1 : depth + 1;
        }
        if (!id2depth[associatedNodeId]) {
          id2depth[associatedNodeId] = associatedNodeDepth;
        }
        // 保存当前节点上下游
        associatedNodeDepthMap[associatedNodeId] = associatedNodeDepth;
      });
      
      // 计算上下游的上下游
      Object.entries(associatedNodeDepthMap).forEach(([nodeId, nodeDepth]) => {
        calcRelativeDepth(nodeId, nodeDepth);
      });
    };

    calcRelativeDepth(primaryId);
  }

  return {
    depth2ids,
    id2depth,
  };
};

粒度聚合和展开

PSM 粒度展开

初始为PSM粒度:

暂时无法在飞书文档外展示此内容

PSM展开后:边关系保持PSM粒度,不额外请求Method、Field粒度边关系数据

暂时无法在飞书文档外展示此内容

Field 粒度聚合

初始为Field粒度:

暂时无法在飞书文档外展示此内容

Field聚合成PSM后:节点按PSM-Method-Field形式渲染,边关系保持Field粒度

暂时无法在飞书文档外展示此内容

数据聚合实现

示例数据,处理前:

// `path_info` 为父级节点列表
const nodes = [
  { id: 'aaa', path_info: [{ id: 'a' }, { id: 'aa' }] },
  { id: 'baa', path_info: [{ id: 'b' }, { id: 'ba' }] },
  { id: 'bab', path_info: [{ id: 'b' }, { id: 'ba' }] },
  { id: 'bac', path_info: [{ id: 'b' }, { id: 'ba' }] },
  { id: 'bba', path_info: [{ id: 'b' }, { id: 'bb' }] },
  { id: 'caa', path_info: [{ id: 'c' }, { id: 'ca' }] }
];

const edges = [
  { source: 'aaa', target: 'baa' },
  { source: 'aaa', target: 'bab' },
  { source: 'aaa', target: 'bac' },
  { source: 'aaa', target: 'bba' },
  { source: 'aaa', target: 'caa' }
];

示例数据,处理后:

const aggregatedNodes = [
  {
    id: 'a',
    children: [{ id: 'aa', children: [{ id: 'aaa' }] }]
  },
  {
    id: 'b',
    children: [
      { id: 'ba', children: [{ id: 'baa' }, { id: 'bab' }, { id: 'bac' }] },
      { id: 'bb', children: [{ id: 'bba' }] }
    ]
  },
  { 
    id: 'c',
    children: [{ id: 'ca', children: [{ id: 'caa' }] }]
  }
];

const aggregatedEdges = [
  { source: 'a', target: 'b' },
  { source: 'a', target: 'c' }
];

代码实现:

import { NodeVariantData, NodeVariant, EdgeVariant } from './types';

interface Options {
  nodes?: NodeVariant<NodeVariantData>[];
  edges?: EdgeVariant[];
}

interface Result {
  nodes: NodeVariant[];
  edges: EdgeVariant[];
}

/**
 * 对血缘图数据源进行聚合,将扁平数据处理成psm->method->field树形数据
 */
export const aggregate = ({ nodes = [], edges = [] }: Options): Result => {
  const nodeMap: Record<string, NodeVariant> = {};
  const aggregatedNodes: NodeVariant[] = [];
  const aggregatedEdges: EdgeVariant[] = [];

  // 聚合 node
  nodes.forEach((node) => {
    if (!nodeMap[node.id]) {
      nodeMap[node.id] = { ...node };
    }

    // 如果有祖先节点列表,则从左往右处理,第一个节点push到aggregatedNodes中,剩余节点作为前一个节点的子节点;
    // 如果没有祖先节点列表,则将当前节点push到aggregatedNodes中。
    const ancestors = node?.data?.ancestors ?? [];

    [...ancestors, node].reduce<NodeVariant | null>((previous, current) => {
      if (!nodeMap[current.id]) {
        nodeMap[current.id] = {
          id: current.id,
          label: current.label,
        };
      }
      const currentNode = nodeMap[current.id];

      if (!previous) {
        if (!aggregatedNodes.find((item) => item.id === current.id)) {
          aggregatedNodes.push(currentNode);
        }
      } else {
        (currentNode.data as NodeVariantData).parentId = previous.id;
        const previousNode = nodeMap[previous.id];
        if (!Array.isArray(previousNode.children)) {
          previousNode.children = [];
        }
        if (!nodeMap[previous.id].children?.find((child) => child.id === current.id)) {
          previousNode.children?.push(currentNode);
        }
      }
      return current;
    }, null);
  });

  const appendRootId = (list: NodeVariant[], rootId?: string) => {
    list.forEach((item) => {
      if (rootId) {
        (item.data as NodeVariantData).rootId = rootId;
      } else {
        (item.data as NodeVariantData).rootId = item.id;
      }
      if (Array.isArray(item.children)) {
        appendRootId(item.children, item.data?.rootId);
      }
    });
  };

  // 增加 rootId,方便聚合 edge
  appendRootId(aggregatedNodes);

  // 聚合 edge
  edges.forEach((edge) => {
    const source = nodeMap[edge.source]?.data?.rootId;
    const target = nodeMap[edge.target]?.data?.rootId;
    if (source && target) {
      const id = `${source}_${target}`;
      if (!aggregatedEdges.find((item) => item.id === id)) {
        aggregatedEdges.push({
          id,
          source,
          target,
        });
      }
    }
  });

  return {
    nodes: aggregatedNodes,
    edges: aggregatedEdges,
  };
};

边的绘制

SVG 路径绘制

如果想了解更多,可以参考如下链接:

SVG元素 说明 示例代码
<line> 用于创建一条连接两个点的直线。 主要属性: - x1 定义起点x坐标 - x2 定义起点y坐标 - y1 定义终点x坐标 - y2 定义终点y坐标 <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> <line x1="0" y1="80" x2="100" y2="20" stroke="black" /> </svg>
<polyline> 用于创建多段直线。 主要属性: - points 定义需要绘制的点的x、y坐标列表 <svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg"> <polyline points="100,100 150,25 150,75 200,0" fill="none" stroke="black" /> </svg>
<path> 用于创建复杂的SVG形状。 主要属性: - d 定义绘制的路径命令列表 <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> <path fill="none" stroke="red" d="M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" /> </svg>

SVG 定义了六种路径命令类型,一共 20 条命令:

  • 移动到(Move To):M、m
  • 画线到(Line To):L、l、H、h、V、v
  • 三次贝塞尔曲线(Cubic Bézier Curve):C、c、S、s
  • 二次贝塞尔曲线(Quadratic Bézier Curve):Q、q、T、t
  • 弧线/椭圆曲线(Arc):A、a
  • 封闭路径:Z、z

SVG 代码

<!-- 普通边 -->
<svg class="edge-layer">
  <defs>
    <!-- 箭头 -->
    <marker
      id="arrow"
      viewBox="0 -5 10 10"
      refX="9"
      refY="0"
      markerWidth="5"
      markerHeight="5"
      orient="auto"
    >
      <path d="M 0,-5 L 10,0 L 0,5" stroke="#C6CACD" fill="#C6CACD" />
    </marker>
  </defs>
  <!-- 一条终点为箭头的边 -->
  <path d="M 920,105 C 960,105 960,105 1000,105" class="path-curve" marker-end="url(#arrow)"></path>
</svg>

<!-- 高亮边 -->
<svg class="edge-layer">
  <defs>
    <!-- 高亮箭头 -->
    <marker
      id="arrow-highlighted"
      viewBox="0 -5 10 10"
      refX="9"
      refY="0"
      markerWidth="5"
      markerHeight="5"
      orient="auto"
    >
      <path d="M 0,-5 L 10,0 L 0,5" stroke="#0077FA" fill="#0077FA" />
    </marker>
  </defs>
  <!-- 一条终点为高亮箭头的边 -->
  <path d="M 920,106 C 960,106 960,105 1000,105" class="path-curve path-active" marker-end="url(#arrow-highlighted)"></path>
</svg>

CSS 代码

.edge-layer {
  width: 100%;
  height: 100%;
}

.edge-layer-highlighted {
  pointer-events: none;
}

.path-curve {
  fill: none;
  stroke: #c6cacd;
  stroke-width: 1;
}

.path-active {
  stroke: #0e42d2;
  stroke-dasharray: 4;
  stroke-width: 1;
}

SVG 动画

采用 CSS 动画实现,通过周期性修改 stroke dash array 的偏移量。

@keyframes dash {
  from {
    stroke-dashoffset: 60;
  }
  to {
    stroke-dashoffset: 10;
  }
}

.path-curve {
  fill: none;
  stroke: #c6cacd;
  stroke-width: 1;
}

.path-active {
  stroke: #0e42d2;
  stroke-dasharray: 4;
  stroke-width: 1;
  animation: dash 4s linear infinite normal;
}

边的绘制场景

边关系 场景:A、B同列 场景:A、B相邻列 场景:A、B跨列
A->A 暂时无法在飞书文档外展示此内容 暂时无法在飞书文档外展示此内容
A->B,A、B同层 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案1:左入右出(不避让): 暂时无法在飞书文档外展示此内容 方案1:左入右出(避让): 暂时无法在飞书文档外展示此内容
A->B,A、B不同层 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案2:从上往下时右入右出,从下往上时左入左出: 暂时无法在飞书文档外展示此内容 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案1:左入右出(不避让): 暂时无法在飞书文档外展示此内容 方案2:左入右出(避让): 暂时无法在飞书文档外展示此内容
A->B, B->A 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案2:从上往下时右入右出,从下往上时左入左出: 暂时无法在飞书文档外展示此内容 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案2:从上往下时右入右出,从下往上时左入左出: 暂时无法在飞书文档外展示此内容 方案3:出入共用一条边 暂时无法在飞书文档外展示此内容 方案1:左入右出: 暂时无法在飞书文档外展示此内容 方案2:从上往下时右入右出,从下往上时左入左出: 暂时无法在飞书文档外展示此内容 方案3:出入共用一条边(不避让): 暂时无法在飞书文档外展示此内容 方案4:出入共用一条边(避让): 暂时无法在飞书文档外展示此内容

折线实现

实现方式:

  • 绘制方式:折线
  • 出入方向:左入右出;若有反向调用,出入共用一条边
  • 避让规则:跨列绘制时不避让同层节点
  • 控制点:起点、终点、一个或两个中间点
import { Position, Rect } from '../types';

export interface Options {
  /** 起始点坐标 */
  start: Position & Rect;
  /** 终止点坐标 */
  end: Position & Rect;
  /** 间距 */
  gap?: number;
  /** 是否绘制自调用连线,默认 false */
  selfCall?: boolean;
  /** 当前起点和终点是否已被绘制过,默认 false */
  existEdge?: boolean;
  /** markerEnd x轴偏移量 */
  offsetX?: number;
}

/**
 * 绘制折线
 * - 需要计算起点、控制点1、控制点2、终点
 */
export const drawLine = (options: Options): string | null => {
  const { start: p1, end: p2, gap = 80, offsetX = 0, selfCall = false } = options;
  // 自调用取消绘制
  if (!selfCall && p1.x === p2.x && p1.y === p2.y) {
    return null;
  }
  // 间距和偏移量
  const dx = gap / 2 + offsetX;
  // 起点
  const start = {
    x: p1.x <= p2.x ? p1.x + p1.width : p1.x, // 起点在前|同列
    y: p1.y + p1.height / 2,
  };
  // 终点
  const end = {
    x: p1.x >= p2.x ? p2.x + p2.width : p2.x, // 终点在前|同列
    y: p2.y + p2.height / 2,
  };
  if (p1.x === p2.x && p1.y === p2.y) {
    // 自调用
    start.y = p1.y + p1.height / 4;
    end.y = p2.y + (p2.height * 3) / 4;
  }
  const middle1 = {
    x: p1.x <= p2.x ? start.x + dx : end.x + dx,
    y: start.y,
  };
  const middle2 = {
    x: middle1.x,
    y: end.y,
  };
  if (p1.x !== p2.x && p1.y === p2.y) {
    // 不同列同一行
    return `M ${start.x},${start.y} ${end.x},${end.y}`;
  }
  // 自调用|同一列|不同列不同行
  return `M ${start.x},${start.y} ${middle1.x},${middle1.y} ${middle2.x},${middle2.y} ${end.x},${end.y}`;
};

三次贝塞尔曲线绘制实现

实现方式:

  • 绘制方式:三次贝塞尔曲线
  • 出入方向:左入右出;若有反向调用,出入共用一条边
  • 避让规则:跨列绘制时不避让同层节点
  • 控制点:起点、起点控制点、终点、终点控制点
import { Position, Rect } from '../types';

export interface Options {
  /** 起始点坐标 */
  start: Position & Rect;
  /** 终止点坐标 */
  end: Position & Rect;
  /** 间距 */
  gap?: number;
  /** 是否绘制自调用连线,默认 false */
  selfCall?: boolean;
  /** 当前起点和终点是否已被绘制过,默认 false */
  existEdge?: boolean;
  /** markerEnd x轴偏移量 */
  offsetX?: number;
}

/**
 * 绘制三次贝塞尔曲线
 *  需要计算起点、起点控制点、终点、终点控制点
 */
export const drawCurve = (options: Options): string | null => {
  const { start, end, gap = 0, selfCall = false, existEdge = false } = options;
  // 自调用取消绘制
  if (!selfCall && start.x === end.x && start.y === end.y) {
    return null;
  }
  // 起点
  const x1 = start.x <= end.x ? start.x + start.width : start.x;
  const y1 = start.y + start.height / 2;
  // 终点
  const x2 = start.x >= end.x ? end.x + end.width : end.x;
  const y2 = end.y + end.height / 2;
  // 起点控制点、终点控制点
  let [xcs, ycs, xce, yce]: number[] = [x1, y1, x2, y2];
  if (start.x < end.x) {
    // 起点在前,终点在后
    const dx = Math.abs(x2 - x1) / 2;
    if (existEdge) {
      if (y1 < y2) {
        // 起点在上
        ycs = y1 + dx;
      } else if (y1 === y2) {
        const dx1 = Math.abs(x1 - x2) / 3;
        // 从上方圆弧过去
        xcs = x1 + dx1;
        ycs = y1 - dx1;
        xce = x2 - dx1;
        yce = y2 - dx1;
      } else {
        // 起点在下
        ycs = y1 - dx;
      }
    } else {
      xcs = x1 + dx;
    }
    xce = x2 - dx;
    return `M ${x1},${y1} C ${xcs},${ycs} ${xce},${yce} ${x2},${y2}`;
  } else if (start.x > end.x) {
    // 终点在前,起点在后
    const baseDistanceX = Math.abs(x1 - x2);
    const baseDistanceY = Math.abs(y1 - y2);
    const dx = baseDistanceX / 2;
    if (existEdge) {
      if (baseDistanceY < 20 || y1 === y2) {
        // 从下方圆弧过去
        const dx1 = baseDistanceX / 3 + baseDistanceY;
        xcs = x1 - dx1;
        ycs = y1 + dx1;
        xce = x2 + dx1;
        yce = y2 + dx1;
      } else if (y1 < y2) {
        // 起点在上
        ycs = y1 + dx;
      } else {
        // 起点在下
        ycs = y1 - dx;
      }
    } else {
      xcs = x1 - dx;
    }
    xce = x2 + dx;
    return `M ${x1},${y1} C ${xcs},${ycs} ${xce},${yce} ${x2},${y2}`;
  } else if (start.x === end.x) {
    // 同一列
    const dex = gap / 2;
    if (start.y === end.y) {
      // 自调用
      xcs = x1 + dex * 2;
      xce = x2 + dex * 2;
      ycs = y1 - dex * 2;
      yce = y2 + dex * 2;
    } else {
      // 控制点计算
      const r = Math.abs(y2 - y1) / 4;
      if (existEdge) {
        xcs = x1 + dex / 2;
        xce = x2 + dex / 2;
      } else {
        xcs = x1 + dex;
        xce = x2 + dex;
      }
      if (start.y < end.y) {
        // 同列起点在上
        ycs = y1 + r;
        yce = y2 - r;
      } else {
        // 同列起点在下
        ycs = y1 - r;
        yce = y2 + r;
      }
    }
    return `M ${x1},${y1} C ${xcs},${ycs} ${xce},${yce} ${x2},${y2}`;
  }
  return null;
};

上下游展开和收起

如何判断是否支持展开和收起上下游

  • 展开上游:节点本身是主节点、终点或者上游节点(即 node.depth <= 0),有潜在上游且上游没有被展开。
  • 收起上游:节点本身是主节点、终点或者上游节点(即 node.depth <= 0),有潜在上游且上游已经被展开。
  • 展开下游:节点本身是主节点或者下游节点(即 node.depth >= 0 且 node 非终点),有潜在下游且下游没有被展开。
  • 收起下游:节点本身是主节点或者下游节点(即 node.depth >= 0 且 node 非终点),且有潜在下游且下游已经被展开。

上下游展开和收起实现方案

方案一:修改原始数据源
  • 优点
    • 不需要考虑被展开/收起节点的粒度。
    • 收起时,不需要处理上游的上游或者下游的下游。
  • 缺点
    • 布局不稳定,有新的边数据加入,会导致重新分层,节点的层级可能会变动。
  • 详细说明
    • 展开上游:查找所有 target 为被展开节点 id 的边,添加到 edges,这些边的 source 添加到 nodes。
    • 收起上游:查找所有 target 为被收起节点 id 的边,从 edges 中移除,这些边的 source 标记为 upstreamIds ,其中 depth < node.depth 的节点从 nodes 移除,其余节点保留。
    • 展开下游:查找所有 source 为被展开节点 id 的边,添加到 edges,这些边的 target 添加到 nodes。
    • 收起下游:查找所有 source 为被收起节点 id 的边,从 edges 中移除,这些边的 target 标记为 downstreamIds ,其中 depth > node.depth 的节点从 nodes 移除,其余节点保留。
方案二:修改聚合和分层后的数据
  • 优点
    • 布局稳定,加入新的边不会影响已有节点位置。
  • 缺点
    • 计算复杂,需要计算被展开/收起节点的边关系、所有上下游关系。
  • 详细说明

本血缘拓扑图采用方案1,上下游关系计算逻辑简单。

实现效果

data-lineage-graph.png

发邮件与我交流

© 2016 - 2024 Ke Qingrong