这是什么需求?为什么没有现成方案!

172 阅读4分钟

前言

这种需求,应该很多同学遇到过吧“在一个图画布中点击节点时异步请求数据,同时保证原有画布不变,做增量布局”。你们有让 PD 妥协吗?

实现

看到这种图相关的需求,我一般会考虑 G6 或者 D3,当然,极端情况下也会自己造轮子。最终选择的是 G6,也并不是因为我是 G6 开发者!

快速接入

由于 G6 没有对业务来说,成本稍高,而且没有现存案例,我们会对其进行简单封装(Ant Design Charts),后面会给出实现代码。

step1:依赖安装

yarn add @ant-design/graphs -S

step2:组件引用

import React, { useRef } from 'react';
import ReactDOM from 'react-dom';
import { RadialGraph } from '@ant-design/graphs';

const DemoRadialGraph = () => {
  const chartRef = useRef();
  const data = {
    nodes: [
      {
        id: '0',
        label: '0',
      },
      {
        id: '1',
        label: '1',
      },
      {
        id: '2',
        label: '2',
      },
      {
        id: '3',
        label: '3',
      },
      {
        id: '4',
        label: '4',
      },
      {
        id: '5',
        label: '5',
      },
      {
        id: '6',
        label: '6',
      },
      {
        id: '7',
        label: '7',
      },
      {
        id: '8',
        label: '8',
      },
      {
        id: '9',
        label: '9',
      },
    ],
    edges: [
      {
        source: '0',
        target: '1',
      },
      {
        source: '0',
        target: '2',
      },
      {
        source: '0',
        target: '3',
      },
      {
        source: '0',
        target: '4',
      },
      {
        source: '0',
        target: '5',
      },
      {
        source: '0',
        target: '6',
      },
      {
        source: '0',
        target: '7',
      },
      {
        source: '0',
        target: '8',
      },
      {
        source: '0',
        target: '9',
      },
    ],
  };

  // 模拟请求
  const fetchData = (node) => {
    return new Promise((resolve, reject) => {
      const data = new Array(Math.ceil(Math.random() * 10) + 2).fill('').map((_, i) => i + 1);
      setTimeout(() => {
        resolve({
          nodes: [
            {
              ...node,
            },
          ].concat(
            data.map((i) => {
              return {
                id: `${node.id}-${i}`,
                label: `${node.label}-${i}`,
              };
            }),
          ),
          edges: data.map((i) => {
            return {
              source: node.id,
              target: `${node.id}-${i}`,
            };
          }),
        });
      }, 1000);
    });
  };

  const asyncData = async (node) => {
    return await fetchData(node);
  };

  const config = {
    data,
    autoFit: false,
    layout: {
      unitRadius: 80,
      /** 节点直径 */
      nodeSize: 20,
      /** 节点间距 */
      nodeSpacing: 10,
    },
    nodeCfg: {
      asyncData,
      size: 20,
      style: {
        fill: '#6CE8DC',
        stroke: '#6CE8DC',
      },
      labelCfg: {
        style: {
          fontSize: 5,
          fill: '#000',
        },
      },
    },
    menuCfg: {
      customContent: (e) => {
        return (
          <button
            onClick={() => {
              chartRef.current.emit('node:dblclick', e);
            }}
          >
            手动拓展(双击节点也可以拓展)
          </button>
        );
      },
    },
    edgeCfg: {
      style: {
        lineWidth: 1,
      },
      endArrow: {
        d: 10,
        size: 2,
      },
    },
    behaviors: ['drag-canvas', 'zoom-canvas', 'drag-node'],
    onReady: (graph) => {
      chartRef.current = graph;
    },
  };

  return <RadialGraph {...config} />;
};

ReactDOM.render(<DemoRadialGraph />, document.getElementById('container'));

实现原理

事件绑定

双击节点时发起数据请求,也可手动 emit已经绑定的事件,布局结束后触发位置变更动画graph.positionsAnimate

/** bind events */
export const bindDblClickEvent = (
  graph: IGraph,
  asyncData: (nodeCfg: NodeConfig) => GraphData,
  layoutCfg?: RadialLayout,
  fetchLoading?: FetchLoading,
) => {
  const onDblClick = async (e: IG6GraphEvent) => {
    const item = e.item as INode;
    const itemModel = item.getModel();
    createLoading(itemModel as NodeConfig, fetchLoading);
    const newData = await asyncData(item.getModel() as NodeConfig);
    closeLoading();
    const nodes = graph.getNodes();
    const edges = graph.getEdges();
    const { x, y } = itemModel;
    const centerNodeId = graph.get('centerNode');
    const centerNode = centerNodeId ? graph.findById(centerNodeId) : nodes[0];
    const { x: centerX, y: centerY } = centerNode.getModel();
    // the max degree about foces(clicked) node in the original data
    const pureNodes = newData.nodes.filter(
      (item) => findIndex(nodes, (t: INode) => t.getModel().id === item.id) === -1,
    );
    const pureEdges = newData.edges.filter(
      (item) =>
        findIndex(edges, (t: IEdge) => {
          const { source, target } = t.getModel();
          return source === item.source && target === item.target;
        }) === -1,
    );

    // for graph.changeData()
    const allNodeModels: GraphData['nodes'] = [];
    const allEdgeModels: GraphData['edges'] = [];
    pureNodes.forEach((nodeModel) => {
      // set the initial positions of the new nodes to the focus(clicked) node
      nodeModel.x = itemModel.x;
      nodeModel.y = itemModel.y;
      graph.addItem('node', nodeModel);
    });

    // add new edges to graph
    pureEdges.forEach((em, i) => {
      graph.addItem('edge', em);
    });

    edges.forEach((e: IEdge) => {
      allEdgeModels.push(e.getModel());
    });
    nodes.forEach((n: INode) => {
      allNodeModels.push(n.getModel() as NodeConfig);
    });
    // 这里使用了引用类型
    radialSectorLayout({
      center: [centerX, centerY],
      eventNodePosition: [x, y],
      nodes: nodes.map((n) => n.getModel() as NodeConfig),
      layoutNodes: pureNodes,
      options: layoutCfg as any,
    });
    graph.positionsAnimate();
    graph.data({
      nodes: allNodeModels,
      edges: allEdgeModels,
    });
  };
  graph.on('node:dblclick', (e: IG6GraphEvent) => {
    onDblClick(e);
  });
};

节点布局

对节点进行拓展时,根据拓展出的节点数量以及节点之间的距离(nodeSpacing) ,计算出下一层级存放当前拓展节点所需的弧,检测下一层级该区域内是否存在重叠节点,没有即符合要求,如果有重叠,拓展节点移动到下一层级,依次检测。当然,也可以选择在对应层级移动节点,或者固定层级节点数量,放不下的移动到下一层级,主要还是看业务需求。

代码:

type INode = {
  id: string;
  x?: number;
  y?: number;
  layer?: number;
  [key: string]: unknown;
};

export type IRadialSectorLayout = {
  /** 布局中心 [x,y] */
  center: [number, number];
  /** 事件节点坐标 */
  eventNodePosition: [number, number];
  /** 画布当前节点信息,可通过 graph.getNodes().map(n => n.getModel()) 获取 */
  nodes: INode[];
  /** 布局节点,拓展时的新节点,会和当前画布节点做去重处理 */
  layoutNodes: INode[];
  options?: {
    /** 圈层半径 */
    unitRadius: number;
    /** 节点直径 */
    nodeSize: number;
    /** 节点间距 */
    nodeSpacing: number;
  };
};

export const radialSectorLayout = (params: IRadialSectorLayout): INode[] => {
  const { center, eventNodePosition, nodes: allNodes, layoutNodes, options = {} } = params;
  const { unitRadius = 80, nodeSize = 20, nodeSpacing = 10 } = options as IRadialSectorLayout['options'];

  if (!layoutNodes.length) layoutNodes;

  // 过滤已经在画布上的节点,避免上层传入重复节点
  const pureLayoutNodes = layoutNodes.filter((node) => {
    return (
      allNodes.findIndex((n) => {
        const { id } = n;
        return id === node.id;
      }) !== -1
    );
  });
  if (!pureLayoutNodes.length) return layoutNodes;

  const getDistance = (point1: Partial<INode>, point2: Partial<INode>) => {
    const dx = point1.x - point2.x;
    const dy = point1.y - point2.y;
    return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
  };

  // 节点裁剪
  const [centerX, centerY] = center;
  const [ex, ey] = eventNodePosition;
  const diffX = ex - centerX;
  const diffY = ey - centerY;
  const allNodePositions: INode[] = [];
  allNodes.forEach((n) => {
    const { id, x, y } = n;
    allNodePositions.push({
      id,
      x,
      y,
      layer: Math.round(getDistance({ x, y }, { x: centerX, y: centerY })) / unitRadius,
    });
  });

  const currentRadius = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2));
  const degree = Math.atan2(diffY, diffX);
  let minRadius = currentRadius + unitRadius;

  let pureNodePositions: Partial<INode>[] = [];
  const getNodesPosition = (nodes: INode[], r: number) => {
    const degreeStep = 2 * Math.asin((nodeSize + nodeSpacing) / 2 / r);
    pureNodePositions = [];
    const l = nodes.length - 1;
    nodes.forEach((n, i) => {
      n.x = centerX + r * Math.cos(degree + (-l / 2 + i) * degreeStep);
      n.y = centerY + r * Math.sin(degree + (-l / 2 + i) * degreeStep);
      pureNodePositions.push({ x: n.x as number, y: n.y as number });
    });
  };

  const checkOverlap = (nodesPosition: INode[], pureNodesPosition: Partial<INode>[]) => {
    let hasOverlap = false;
    const checkLayer = Math.round(minRadius / unitRadius);
    const loopNodes = nodesPosition.filter((n) => n.layer === checkLayer);
    for (let i = 0; i < loopNodes.length; i++) {
      const n = loopNodes[i];
      // 因为是同心圆布局,最先相交的应该是收尾节点
      if (
        getDistance(pureNodesPosition[0], n) < nodeSize ||
        getDistance(pureNodesPosition[pureNodesPosition.length - 1], n) < nodeSize
      ) {
        hasOverlap = true;
        break;
      }
    }
    return hasOverlap;
  };
  getNodesPosition(pureLayoutNodes, minRadius);
  while (checkOverlap(allNodePositions, pureNodePositions)) {
    minRadius += unitRadius;
    getNodesPosition(pureLayoutNodes, minRadius);
  }
  return layoutNodes;
};

问题

  1. 拓展节点过多,一圈存放不下怎么办?