「AntV」如何使用 G6 进行指标体系可视化

4,114 阅读19分钟

一、需求背景

指标体系指的是若干个相互联系的统计指标所组成的有机体。指标体系(IndicationSystem-IS)的建立,是进行预测或评价研究的前提和基础,它是将抽象的研究对象按照其本质属性和特征的某一方面的标识分解成为具有行为化、可操作化的结构,并对指标体系中每一构成元素(即指标)赋予相应权重的过程。

二、功能要求

  1. 以思维脑图形式展示所属业务域、分类、及指标;
  2. 节点分为分类和指标两大类,不同节点之间使用图标和样式区分,节点支持悬浮菜单,菜单根据节点类型进行区分;
  3. 指标节点支持编辑指标属性、新增子节点、设置预警规则、预览数据、删除节点等;
  4. 编辑属性、数据预览、设置预警规则等,支持点击弹框;
  5. 非叶子节点,支持展开与收起,支持一键展开与收起;
  6. 节点支持模糊检索,并支持定位到画布;
  7. 节点和边样式参考UI实现;

三、设计效果和优化建议

3.1 优化前效果

3.2 设计效果

3.3 优化建议

对比设计效果,测试小姐姐提了几条建议:

  1. 当节点过少或者过多时,初始化出现过大、或者看不见的情况;
  2. 节点悬浮菜单位置飘忽不定;
  3. 节点水平间距有点大;
  4. 画布背景与节点对比度太差,看不清节点边界;
  5. 节点点击、hover、选中效果等没有区分,可视化效果不好;
  6. 当放大时,节点图标很模糊,质感差;
  7. 节点整体对比度低,不明显;节点空白太多;不同节点类型的区分度低;
  8. 连线棱角太鲜明,不够圆润;

四、踩坑 or 经验分享

4.1 技术选型

通读Antv官网后,了解到AntV图可视分析解决⽅案分为三大类,即对统计数据、关系数据、地理数据的可视化解决⽅案,对应的开源技术分别是:G2/G2Plot/S2、G6/Graphin/X6/XFlow、L7/L7Plot等。基于侧重展示还是侧重编辑,可以分为图可视化引擎和图编辑引擎。基于侧重的前端技术栈,可以分为:Vue和React等。

我们的指标数据,属于关系数据,侧重于图可视化,且前端技术栈为Vue全家桶,因此最终选择G6作为我们的可视化引擎。

4.2 确定图布局

图布局是指图中节点的排布方式,根据图的数据结构不同,布局可以分为两类:一般图布局、树图布局。其中,树布局是一种能很好展示有一定层次结构数据的布局方式。更多知识请参考官网链接:树图布局Layout

名称标识描述
紧凑树布局compactBox从根节点开始,同一深度的节点在同一层,并且布局时会将节点大小考虑进去。
生态树布局dendrogram不管数据的深度多少,总是叶节点对齐。不考虑节点大小,布局时将节点视为 1 个像素点。
缩进树布局indented每个元素会占一行/一列。
脑图布局mindmap深度相同的节点将会被放置在同一层,与 compactBox 不同的是,布局不会考虑节点的大小。

由于我们的指标体系,是具有父子层级关系的数据,且业务团队在调研时倾向于使用思维脑图,于是最终选定:脑图布局(mindmap)。

4.3 初始化时,忽大忽小的问题

当前实例化选项为:

const graph = new G6.TreeGraph({
  container: 'container',
  width,
  height,
  fitView: true,
  fitViewPadding: 10,
  layout: {
    type: 'mindmap',
    getVGap: () => 24,
    getHGap: () => 100,
    getSide: () => 'right'
  },
  defaultEdge: {
    type: 'mindmap-edge'
  },
  modes: {
    default: ['mindmap-behavior', collapseBehavior, 'drag-canvas', 'zoom-canvas']
  }
});
graph.data(this.graphData);
graph.render();

凡事不决找官网,请参考官网链接:fitView 失败

分析原因

实例化选项中设置了fitView = true,也就是自适应视口。因此,只有一个节点时,节点变得非常大;节点很多时,节点变得很小。

解决方案

  • 设置最小和最大zoom,防止节点过大;
  • 设置fitcenter: true,初始化时,不进行缩放;
  // fitView: true,
  // fitViewPadding: 10,
  fitCenter: true,
  minZoom: 0.01,
  maxZoom: 2,

4.4 悬浮菜单的位置如何设置?

在进行地理数据可视化时,只要给定某个点的经纬度坐标,该点就会牢牢的固定在某个位置上,而在脑图画布上,会略微复杂一点,因为涉及三个坐标系,即:clientX / clientY、canvasX / canvasY、pointX / pointY。更多知识请参考官网链接:G6坐标系深度解析

三个坐标系的关系

类型区别备注
clientX /clientYDOM 相关的坐标系,取值为整数,是相对于浏览器的坐标系,原点位于浏览器内容范围的左上角坐标系位置不随滚动条变化,当发生滚动时,Container DOM的左上角坐标会发生变化
canvasX /canvasYDOM 相关的坐标系,取值为整数,是Container DOM 的自身坐标系,原点在 Container DOM 的左上角Container DOM 右下角的 canvasX /canvasY坐标为(画布宽,画布高)
pointX /pointY真正绘制图形时的坐标系,取值可带小数,当图没有缩放和平移时,canvasX /canvasY 与 pointX /pointY 两个坐标系是完全重合的节点的 (x, y) 等都是与 pointX/pointY 坐标系相对应的,图的缩放、平移其实是整个 pointX/pointY 坐标系的缩放和平移;

G6事件中的坐标

在 G6 的事件中,event 会包含当前鼠标操作位置的三种坐标值,它们的变量名与上述三种坐标系对应关系如下:

  • event.x, event.y => pointX/pointY;
  • event.canvasX, event.canvasY => canvasX/canvasY;
  • event.clientX, event.clientY => clientX/clientY。

可以发现后两者的名字是直接对应的,我们只需要注意 event 中的 x 和 y 对应的是 pointX/pointY 坐标系。

使用坐标系

确定挂载位置

官方推荐两种挂载悬浮 DOM 的方式:挂载在 body 上、挂载在 Container DOM 上。我们选择了后者,即与 canvas 标签同一父容器。

<div id="container" class="container">
  <ul
    class="tooltip-contextmenu"
    :style="`visibility:${showTooltip ? 'visible' : 'hidden'}`"
    @mouseleave="showTooltip = false;onTooltip = false;"
    @mouseenter="onTooltip = true"
  >
    <li 
	  @click="menuClick(item.type)" 
	  :class="item.class" 
	  v-for="(item, index) in menuList" 
	  :key="item.key">
      <div class="tooltip-wrap">
        <img :src="item.img" />
        <div>{{ item.name }}</div>
      </div>
    </li>
  </ul>
</div>

设置样式

主要是将 position设置为 absolute :

.tooltip-contextmenu {
  position: absolute;
  list-style: none;
  border-radius: 4px;
  padding: 6px 0;
  li {
    cursor: pointer;
    text-align: left;
    line-height: 32px;
    &:hover {
      background-color: #f2f6fc;
      cursor: pointer;
      color: #3077f1;
    }
  }
}

设置动态坐标

众所周知,position: absolute 的 DOM 元素相对于父容器定位。我们把悬浮 DOM 挂载在 Container DOM 上,它的父容器是 Container DOM,我们可以使用 canvasX/canvasY 来指定它的 marginLeft/marginTop。

  • 在点击画布的位置上放置 DOM:
graph.on('canvas:click', event => {
  menuDOM.style.marginLeft = event.canvasX;
  menuDOM.style.marginTop = event.canvasY;
});
  • 在某个节点的位置上放置 DOM:
const node = graph.getNodes()[0];
const { x, y } = node.getModel(); // 获得该节点的位置,对应 pointX/pointY 坐标
const canvasXY = graph.getCanvasByPoint(x, y);
menuDOM.style.marginLeft = canvasXY.x;
menuDOM.style.marginTop = canvasXY.y;

注意:如果使用了错误的坐标系来给定悬浮 DOM 元素的位置,将会出现偏移,在图有缩放、平移等变化时,偏移更加严重。

4.5 节点水平间距有点大,不紧凑

节点间距需要查询布局API,更多知识请参考官网链接:Mindmap API

参数名类型示例/可选值默认值说明
directionString'H' / 'V''H'layout 的方向。
getHeightFunction(d) =>{ return 10; }undefined节点高度的回调函数
getWidthFunction(d) =>{ return 20; }undefined节点宽度的回调函数
getVGapFunction(d) =>{ return 100; }18节点纵向间距的回调函数
getHGapFunction(d) =>{ return 50; }18节点横向间距的回调函数
getSideFunction(d) =>{ return 'left'; }'right'节点排布在根节点的左侧/右侧。

因此,只需要将实例化选项中布局的横向间距和纵向间距调小就可以了:

getVGap: () => 4// 节点纵向间距的回调函数
getHGap: () => 78// 节点横向间距的回调函数

4.6 画布背景与节点对比度太差

  • 减弱画布背景的对比度:实例化Grid插件并配置插件到图上,以及增加背景的灰度;
// 实例化 grid 插件
const grid = new G6.Grid();
// 实例化图
const graph = new G6.Graph({
  // ...                        // 其他配置项
  plugins: [grid], // 将 grid 实例配置到图上
});
// 调整画布背景色
#container {
  background-color: #f0f2f5;
}
  • 增加节点对比度,见下文:4.7 自定义节点

4.7 自定义节点

G6有8种内置节点,包括 circle、rect、diamond、triangle、star、image、modelRect、donut。若不满足要求,还可以通过G6.registerNode进行自定义节点,方便用户开发更加定制化的节点,包括含有复杂图形的节点、复杂交互的节点、带有动画的节点等。更多知识请参考官网链接:自定义节点。 下面是我们开发的自定义节点的代码:

// 各种图标
const minusImg = `images/indexMap/minus.svg`; // 折叠
const addImg = `images/indexMap/add.svg`; // 展开
const whiteMore = `images/indexMap/ico_more_white.svg`; // 更多 白色
const blueMore = `images/indexMap/ico_more_blue.svg`; // 更多 蓝色
const errorMore = `images/indexMap/ico_more_error.svg`; // 更多 红色
// ... // 其他图标

// 公共样式
const shadowCfg = {
  shadowOffsetX: 0,
  shadowOffsetY: 4,
  shadowBlur: 16
};
const normalClickAndHover = {
  stroke: 'rgba(76,125,254,0.48)',
  ...shadowCfg,
  shadowColor: 'rgba(76, 125, 254, 0.48)'
};
const alertClickAndHover = {
  stroke: '#ff6565',
  ...shadowCfg,
  shadowColor: 'rgba(255,101,101,0.48)'
};

// 自定义节点
G6.registerNode(
  'mindmap-child-node',
  {
    options: {
      stateStyles: {
        click: {
          'mindmap-node-wraper': normalClickAndHover,
          'mindmap-node-text': { fill: '#4C7DFE' },
          'mindmap-node-more-icon': { img: blueMore },
          'mindmap-node-eye-icon': { img: sjylImg }
        },
        hover: {
          'mindmap-node-wraper': normalClickAndHover,
          'mindmap-node-text': { fill: '#4C7DFE' },
          'mindmap-node-more-icon': { img: blueMore },
          'mindmap-node-eye-icon': { img: sjylImg }
        },
        select: {
          'mindmap-node-wraper': normalClickAndHover,
          'mindmap-node-text': { fill: '#4C7DFE' }
        }
      }
    },
    draw(cfg, group) {
      const { size, eleMargin, label, iconSize, radius, hasMoreIcon } = cfg; // 自定义属性
      const [width, height] = size; // width 为 label 宽度
      // 节点 keyShape
      const shape = group.addShape('rect', {
        attrs: {
          // 左侧留白 + 右侧留白 + 文字宽度 + (图标宽度 + 元素间隔宽度)
          width: leftSpace + rightSpace + width + (iconSize + eleMargin) * (cfg.type == 1 ? 3 : 2),
          height: height,
          radius: radius,
          fill: '#FFFFFF',
          stroke: 'rgba(76,125,254,0.48)',
          cursor: 'pointer'
        },
        name: 'mindmap-node-wraper'
      });
      // 左侧图标
      let frontImgIcon = cfg.type == 1 ? normalIndex : normalCatalog;
      group.addShape('image', {
        attrs: {
          x: leftSpace,
          y: height / 2 - iconSize / 2,
          width: iconSize,
          height: iconSize,
          img: frontImgIcon,
          cursor: 'pointer'
        },
        name: 'mindmap-node-left-icon'
      });
      // 节点文本
      group.addShape('text', {
        attrs: {
          text: label,
          x: leftSpace + iconSize + eleMargin,
          y: height / 2 + 1,
          fill: '#000',
          textBaseline: 'middle',
          fontSize: 14,
          lineHeight: height,
          cursor: 'pointer'
        },
        name: 'mindmap-node-text'
      });
      // 数据预览按钮
      group.addShape('image', {
        attrs: {
          x: leftSpace + width + iconSize + eleMargin * 2,
          y: height / 2 - iconSize / 2,
          width: iconSize,
          height: iconSize,
          cursor: 'pointer'
        },
        name: 'mindmap-node-eye-icon'
      });
      // 更多按钮
      hasMoreIcon &&
        group.addShape('image', {
          attrs: {
            x: leftSpace + width + (iconSize + eleMargin) * (cfg._type == 1 ? 2 : 1) + eleMargin,
            y: height / 2 - iconSize / 2,
            width: iconSize,
            height: iconSize,
            // img: moreImg  初始化不显示,hover才显示
            cursor: 'pointer'
          },
          name: 'mindmap-node-more-icon'
        });

      const hasChildren = cfg.children && cfg.children.length;
      const iconX = cfg.type == 1 ? (iconSize + eleMargin) * 3 + 11 : (iconSize + eleMargin) * 2 + 19;
      hasChildren &&
        group.addShape('image', {
          attrs: {
            x: leftSpace + rightSpace + width + iconX,
            y: height / 2 - iconSize / 2,
            width: iconSize,
            height: iconSize,
            img: cfg.collapsed ? addImg : minusImg,
            cursor: 'pointer'
          },
          name: 'mindmap-node-collapsed-icon'
        });
      return shape;
    },
    update(cfg, node) {
      const group = node.getContainer();
      const children = group.get('children');
      const iconShape = children.find(shape => shape.cfg.name == 'mindmap-node-collapsed-icon');
      iconShape &&
        iconShape.attr({
          img: cfg.collapsed ? addImg : minusImg
        });
    }
  },
  'single-node'
);

自定义节点的 API 为 G6.registerNode(typeName: string, nodeDefinition: object, extendedNodeType?: string) ,其参数如下:

  • typeName:该新节点类型名称;
  • extendedNodeType:被继承的节点类型,可以是内置节点类型名,也可以是其他自定义节点的类型名。extendedNodeType 未指定时代表不继承其他类型的节点;
  • nodeDefinition:该新节点类型的定义,其中必要函数详见 自定义机制 API。当有 extendedNodeType 时,没被复写的函数将会继承 extendedNodeType 的定义。

关于继承

自定义节点时,若给定了 extendedNodeType,如 draw,update,setState 等必要的函数若不在 nodeDefinition 中进行复写,将会继承 extendedNodeType 中的相关定义。常见问题:

  • Q:节点/边更新时,没有按照在 nodeDefinition 中自定义实现的 draw 或 drawShape 逻辑更新。例如,有些图形没有被更新,增加了没有在 draw 或 drawShape 方法中定义的图形等。
  • A:由于继承了 extendedNodeType,且在 nodeDefinition 中没有复写 update 方法,导致节点/边更新时执行了 extendedNodeType 中的 update 方法,从而与自定义的 draw 或 drawShape 有出入。可以通过复写 update 方法为 undefined 解决。当 update 方法为 undefined 时,节点/边的更新将会执行 draw 或 drawShape 进行重绘。
  • single-node 是所有节点的基类,包括所有内置节点。

关于坐标系

节点内部所有图形使用相对于节点自身的坐标系,即 (0, 0) 是该节点的中心。而节点的坐标是相对于画布的,由该节点 group 上的矩阵控制,自定义节点中不需要用户感知。若在自定义节点内增加 rect 图形,要注意让它的 x 与 y 各减去其长与宽的一半。

关于update

当定义了 update 方法,则不论是否指定 registerNode 的第三个参数,在节点更新时都会执行复写的 update 函数逻辑。

关于name

在 G6 3.3 及之后的版本中,必须指定 name,可以是任意字符串,但需要在同一个自定义元素类型中保持唯一性。

关于优化性能

  • 当图中节点或边通过 graph.update(item, cfg) 重绘时,默认情况下会调用节点的 draw 方法进行重新绘制。在数据量大或节点上图形数量非常多(特别是文本多)的情况下,draw 方法中对所有图形、赋予样式将会非常消耗性能。
  • 在自定义节点时,重写 update 方法,在更新时将会调用该方法替代 draw。我们可以在该方法中指定需要更新的图形,从而避免频繁调用 draw 、全量更新节点上的所有图形。当然,update 方法是可选的,如果没有性能优化的需求可以不重写该方法。

我们在实现 mindmap-child-node 的过程中,重写了 update 方法,找到需要更新的 shape 进行更新,从而优化性能。寻找需要更新的图形可以通过:

group.get('children')[0] 找到 关键图形 keyShape,也就是 draw 方法返回的 shape
group.get('children') 找到节点的所有图形数组,然后可以通过图形 name 进行查找

下面代码仅更新了 mindmap-child-node 的展开收起按钮的图标:

update(cfg, node) {
  const group = node.getContainer();
  const children = group.get('children');
  const iconShape = children.find(shape => shape.cfg.name == 'mindmap-node-collapsed-icon');
  iconShape &&
    iconShape.attr({
      img: cfg.collapsed ? addImg : minusImg
    });
}

关于动画

nodeDefinition中的 afterDraw,afterUpdate 方法一般用于扩展已有的节点,例如:在节点上附加图片,在节点增加动画等。下面代码是获取节点中的image,并旋转:

afterDraw(cfg, group) {
  // 获取 image 图形
  const image = group.get('children')[1]; // 按照添加的顺序
  // 执行旋转动画
  image.animate((ratio) => {
    const matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
    const toMatrix = Util.transform(matrix, [
      ['r', ratio * Math.PI * 2]
    ]) ;
    return {
      matrix: toMatrix
    };
  }, {
    repeat: true
    duration: 3000,
    easing: 'easeCubic'
  });
}

关于锚点

节点上的 锚点 anchorPoint 作用是确定节点与边的相交的位置,看下面的场景:

image3.png image4.png

(左)没有设置锚点时,(右)设置了锚点后。

getAnchorPoints 方法仅在需要限制与边的连接点时才需要复写,也可以在数据中直接指定。

getAnchorPoints() {
  return [
    [0, 0.5], // 左侧中间
    [1, 0.5], // 右侧中间
  ];
}

关于state

G6 中的 state,指的是节点或边的状态,包括交互状态和业务状态两种。说明如下:

  • 交互状态是与具体的交互动作密切相关的,如用户使用鼠标选中某个节点则该节点被选中,hover 到某条边则该边被高亮等。
  • 业务状态是与交互动作无关的,与具体业务逻辑强相关的,也可理解为是强数据驱动的。如某个任务的执行状态、某条申请的审批状态等,不同的数据值代表不同的业务状态。
  • 在 G6 中,配置交互状态和业务状态的方式是相同的。对于不想深入理解 G6 的用户,其实不用区分交互状态和业务状态的区别,使用相同的方式定义状态,完全没有理解成本。

在 G6 中,有三种方式配置不同状态的样式:

  • 在实例化 Graph 时,通过 nodeStateStyles 和 edgeStateStyles 对象定义;
  • 在节点/边数据中,在 stateStyles 对象中定义状态;
  • 在自定义节点/边时,在 options 配置项的 stateStyles 对象中定义状态。

建议使用 graph.clearItemStates 来取消 graph.setItemState 设置的状态。

graph.clearItemStates 支持一次取消单个或多个状态。
graph.setItemState(item, 'bodyState', 'health');
graph.setItemState(item, 'selected', true);
graph.setItemState(item, 'active', true);
// 取消单个状态
graph.clearItemStates(item, 'selected');
graph.clearItemStates(item, ['selected']);
// 取消多个状态
graph.clearItemStates(item, ['bodyState:health', 'selected', 'active']);

关于图标和增加节点对比度

  • 所有节点图标更换为svg格式,避免失真;
  • 文本颜色调整为纯黑;
  • 调整节点边框颜色,并增加阴影,格式如下:
attrs: {
  //... // 其他属性
  stroke: 'rgba(76,125,254,0.48)',
  shadowOffsetX: 0,
  shadowOffsetY: 4,
  shadowBlur: 16,
  shadowColor: 'rgba(76, 125, 254, 0.48)'
}

4.8 自定义边

下面使我们开发的自定义边代码:

G6.registerEdge('mindmap-edge', {
  draw(cfg, group) {
    const { startPoint, endPoint } = cfg;
    const yDiff = endPoint.y - startPoint.y;
    const radius = 10;
    const spaceAppend = (endPoint.x - startPoint.x) / 3;
    let path =
      yDiff == 0
        ? [
            ['M', startPoint.x, startPoint.y],
            ['L', endPoint.x, endPoint.y]
          ]
        : [
            ['M', startPoint.x, startPoint.y],
            ['L', startPoint.x + spaceAppend, startPoint.y],
            ['L', startPoint.x + spaceAppend, yDiff > 0 ? endPoint.y - radius : endPoint.y + radius],
            ['Q', startPoint.x + spaceAppend, endPoint.y, startPoint.x + spaceAppend + radius, endPoint.y],
            ['L', endPoint.x, endPoint.y]
          ];
    return group.addShape('path', {
      attrs: {
		...cfg,
        path: path,
        stroke: '#4c7dfe'
      }
    });
  }
});
  • 上面自定义边中的 startPoint 和 endPoint 分别是是边两端与起始节点和结束节点的交点;
  • 可以通过修改节点的锚点(边连入点)来改变 startPoint 和 endPoint 的位置。
  • 边过细时点击很难被击中,可以设置 lineAppendWidth 来提升击中范围。

关于箭头

G6 3.4.1 后的自定义箭头坐标系有所变化。如下图所示,左图为 G6 3.4.1 之前版本的演示,右图为 G6 3.4.1 及之后版本的演示。箭头由指向 x 轴负方向更正为指向 x 轴正方向。同时,偏移量 d 的方向也发生响应变化。不变的是,自定义箭头本身坐标系的原点都与相应边 / path 的端点重合,且自定义箭头的斜率与相应边 / path 端点处的微分斜率相同。

image5.jpeg (左)v3.4.1 之前的自定义箭头坐标系演示。(右)v3.4.1 及之后版本的自定义箭头坐标系演示。

4.9 自定义交互 Behavior

Behavior 是 G6 提供的定义图上交互事件的机制。理论上, G6 上的所有基础图形、Item(节点/边)都能通过事件来进行操作。G6 除了提供丰富的 内置交互行为 Behavior ,但由于场景不一样,业务不一样,同样的目的需要的交互都不一样,如:

  • 有些系统需要从工具栏上点击后添加节点,有些系统需要从面板栏上拖出新的节点;
  • 有的业务添加边需要从锚点上拖拽出来,而有些直接点击节点后就可以拖拽出边;
  • 有些边可以连接到所有节点上,而有些边不能连接到具体某个节点的某个锚点上;
  • 所有的交互的触发、持续、结束都要允许能够进行个性化的判定;

因此,G6还提供了自定义交互行为的机制,方便用户开发更加定制化的交互行为。

下面是我们开发的自定义交互代码:

G6.registerMindmapBehavior = function(vueObject) {
  G6.registerBehavior('mindmap-behavior', {
    getEvents() {
      return {
        'node:click': 'clickNode',
        'canvas:click': 'clickCanvas',
        'node:mouseover': 'overNode',
        'node:mouseleave': 'leaveNode'
      };
    },
    // 点击节点事件
    clickNode(evt) {
      const graph = this.graph;
      const model = evt.item.getModel();
      const targetShapeName = evt.target.cfg.name;
      // 折叠收起按钮、预览按钮
      if (targetShapeName != 'mindmap-node-collapsed-icon' && targetShapeName != 'mindmap-node-eye-icon') {
        // 清空选中的nodes选中状态
        const clickNodes = graph.findAllByState('node', 'click');
        clickNodes.forEach(node => {
          graph.setItemState(node, 'click', false);
        });
        // 设置当前节点选中状态
        graph.setItemState(evt.item, 'click', true);
        // 打开编辑抽屉
        vueObject.isAdd = false;
        vueObject.currentNode = model;
        vueObject.drawer = true;
      }
      if (targetShapeName == 'mindmap-node-eye-icon') {
        // 清空选中的nodes选中状态
        const clickNodes = graph.findAllByState('node', 'click');
        clickNodes.forEach(node => {
          graph.setItemState(node, 'click', false);
        });
        // 设置当前节点选中状态
        graph.setItemState(evt.item, 'click', true);
        vueObject.currentNode = model;
      }
      if (evt.shape.get('name') == 'mindmap-node-collapsed-icon') {
        const item = evt.item;
        if (!model.collapsed) {
          // 当处于展开状态,点击收起时,需要将该节点及其子节点全部折叠
          let dfs = node => {
            node._cfg.children &&
              node._cfg.children.forEach(children => {
                dfs(children);
                let modelC = children.getModel();
                modelC.collapsed = true;
                graph.updateItem(children, modelC);
              });
          };
          dfs(item);
        }
        model.collapsed = !model.collapsed;
        graph.updateItem(item, model); // 必须更新,不然图标不会变
        graph.layout();
      }
    },
	// 点击画布
    clickCanvas() {
      if (vueObject.choosedItem) {
        let node = this.graph.findById(vueObject.choosedItem.id);
        node && this.graph.setItemState(vueObject.choosedItem.id, 'select', false);
      }
    },
    overNode(evt) {
      const graph = this.graph;
      graph.getNodes().forEach(node => {
        graph.setItemState(node, 'hover', false);
      });
      graph.setItemState(evt.item, 'hover', true);
      // 显示菜单
      if (evt.shape.get('name') == 'mindmap-node-more-icon') {
        const model = evt.item.get('model');
        vueObject.showTooltip = true;
        vueObject.model = model;
        vueObject.currentNode = model;
        if (vueObject.showTooltip) {
          let dom = document.querySelector('.tooltip-contextmenu');
          const keyBox = evt.target.cfg.cacheCanvasBBox;
          dom.style.marginLeft = keyBox?.minX + 'px';
          dom.style.marginTop = keyBox?.maxY + 'px';
        }
      }
    },
    leaveNode(evt) {
      const graph = this.graph;
      graph.getNodes().forEach(node => {
        graph.setItemState(node, 'hover', false);
      });
      graph.setItemState(evt.item, 'hover', false);
      setTimeout(() => {
        if (!vueObject.onTooltip) {
          vueObject.showTooltip = false;
        }
      }, 10);
    }
  });
};

我们自定义交互时,将注册动作包裹到匿名函数中,并挂载到G6上,在使用时:

  • 调用函数,并将vue的组件实例this传进去,便于操作实例数据;
registerMindmapBehavior(this);
  • 在Graph的实例化选项中增加modes设置,代码如下:
modes: {
  default: ['mindmap-behavior', 'drag-canvas', 'zoom-canvas']
}

更多知识请参考官网链接:交互模式 Mode

点击节点事件 clickNode

  • 首先获取点击节点的具体图形的 name;
  • 若是预览数据按钮,清除其他节点的click状态,设置当前节点为选中状态,并将当前节点data缓存下来,交给vue实例进行监听,打开数据预览弹框;
  • 若是展开收起按钮,欲想收起时,需要将该节点及其子节点全部折叠;
  • 若是其他图形,清除其他节点的click状态,设置当前节点为选中状态,并将当前节点data缓存下来,打开编辑弹框;

点击画布事件 clickCanvas

如果有搜索定位的节点,将它的状态设置为 select;

节点hover事件 overNode

  • 清除其他节点的hover状态,设置当前节点为hover状态;
  • 获取点击节点的具体图形的 name;
  • 若是更多图标,设置悬浮菜单位置,并显示悬浮菜单,并将当前节点data缓存下来;

节点leave事件 leaveNode

  • 将所有节点的hover状态设置false;
  • 关闭悬浮菜单;

五、优化后效果

image6.png 优化后,获得了测试小姐姐的一致好评。

六、使用心得和未来规划

6.1 使用心得

基于使用G6的经历,给出一些在开发过程中的体会:

  1. 相较于d3.js、mxgraph、cytoscape.js等,很容易上手,对于一般的可视化场景,略微配置就可以达到不错的效果;
  2. 文档很翔实,包括设计原则、组件设计规范、使用教程、API文档等,特别的,还给出了大量的图表示例;
  3. 扩展性很强,且很容易配置,如自定义节点、自定义边、自定义交互、自定义布局等;
  4. 使用最多的功能,包括:自定义节点、自定义交互和事件;
  5. 在理解和使用G6的三个坐标系上,花了很多时间,费了不少功夫,但使用起来确实很香;
  6. 令我很头疼的是图布局,客户原始需求经常要做图美化,而对场景和数据不加限制,希望官方能增加一些自定义图布局的例子,对图布局的算法多进行一些源码解读;

6.2 未来规划

  1. 交互规范的统一:参考官网全局交互,梳理既符合业界规范,又适应我们客户使用习惯的交互动作,如单击、双击、右击、hover、拖拽、新增节点、新增边、属性编辑等;
  2. 画布工具的组件化,包括工具栏、视图栏、输出面板、搜索栏、导入导出、截图等。

参考链接