「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

lxf2023-02-16 15:49:42

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

前言:这是一篇面向没接触过流程图实现的小白群体的文章,所以不会有较难懂的原理的东西,看完你就会使用一套图流程引擎框架来实现一些流程图需求,

晋级流程图是什么?

在刚刚过去的英雄联盟S12中,可能大家还沉浸在中国队痛失决赛的悲痛中,洗把脸,日子还是要过,跟着我,来学习一点新技术~

在各个直播平台中,我们肯定见过这样的队伍晋级图:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

来自维基百科的图

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

来自b站直播页的图

现在打开b站赛事专题页,我们仍可以看到这样的流程图:

链接

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

如果我们来自己实现,看看如何用前端技术,来实现这样的 "流程图"。

技术调研和选型

分析

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的? F12打开b站页面,我们发现阿b的程序猿是用div实现的节点加svg写死path实现的连线,这种实现有点木讷,一旦产品和设计姐姐要求调整下位置/样式,svg的线path就得重新调数值,节点div位置样式也得重新调,不够灵活,多加一些无用班。

选型和对比

所以我想用市面上的一些图引擎来实现,节点和线可以拖动调整,同时样式支持自定义调整等。调研了下现在市面主流的框架:bpmn.js,x6,logicflow。

bpmn是国外搞得一套流程图框架,研发比较早,已经较完善,缺点是文档只支持英文,自定义扩展比较难,底层设计个人不是很喜欢,和现代前端框架思想有偏移,可能是做得比较早的原因。

x6和logicflow都是国内自研的图编辑引擎,综合比较了下,决定选用logicflow来实现,主要是logicflow图开发基于MVVM,和我们平时写vue react有类似之处,且支持bpmn规范,底层用ts实现,提示友好,主打自定义节点和插件扩展上,实现起来更灵活,不用担心发现做了一半实现不了的问题。

logicflow介绍

logicflow文档:logic-flow.org/

github地址:github.com/didi/LogicF… (点点star,鼓励开发者继续维护升级)

LogicFlow 是一款开源的流程图编辑框架,提供了一系列流程图交互、编辑所必需的功能和灵活的节点自定义、插件等拓展机制。LogicFlow支持前端研发自定义开发各种逻辑编排场景,如流程图、ER图、BPMN流程等。在工作审批配置、机器人逻辑编排、无代码平台流程配置都有较好的应用。

为了方便,以下logicflow简称lf。

实现思路

大概阅读过一遍logicflow的文档后,对实现b站这样一个流程图心里有了一个大概思路:

  1. 动态创建几个方形节点。
  2. 节点自定义编码,做成b站流程图节点的样子。
  3. 拖拽这些节点到合适的位置,调用logicflow的全局获取数据方法,保存到代码里。
  4. 拖拽节点之间连线,自定义连线样式,调用logicflow的全局获取数据方法,保存到代码里。
  5. 微调节点和连线的数据坐标,保证绝对对齐和一些重合效果等。
  6. ....(其他扩展功能)

编码

初始化

logiclfow与框架无关(vue,react都可使用),我们用vue-cli创建一个工程,先new一个画布出来,这块可以参考 参考

想动态化创建快速创建几个节点出来,我们可以直接启用logicflow内置的拖拽面板插件:

import LogicFlow from '@logicflow/core';
import "@logicflow/core/dist/style/index.css";
import { DndPanel, SelectionSelect } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css'

const lf = new LogicFlow({
  container: document.querySelector('#graph'),
  plugins: [DndPanel, SelectionSelect]
});

lf.extension.dndPanel.setPatternItems([
  {
        type: 'rect',
        label: '矩形节点',
        icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAEFVwZaAAAABGdBTUEAALGPC/xhBQAAAqlJREFUOBF9VM9rE0EUfrMJNUKLihGbpLGtaCOIR8VjQMGDePCgCCIiCNqzCAp2MyYUCXhUtF5E0D+g1t48qAd7CCLqQUQKEWkStcEfVGlLdp/fm3aW2QQdyLzf33zz5m2IsAZ9XhDpyaaIZkTS4ASzK41TFao88GuJ3hsr2pAbipHxuSYyKRugagICGANkfFnNh3HeE2N0b3nN2cgnpcictw5veJIzxmDamSlxxQZicq/mflxhbaH8BLRbuRwNtZp0JAhoplVRUdzmCe/vO27wFuuA3S5qXruGdboy5/PRGFsbFGKo/haRtQHIrM83bVeTrOgNhZReWaYGnE4aUQgTJNvijJFF4jQ8BxJE5xfKatZWmZcTQ+BVgh7s8SgPlCkcec4mGTmieTP4xd7PcpIEg1TX6gdeLW8rTVMVLVvb7ctXoH0Cydl2QOPJBG21STE5OsnbweVYzAnD3A7PVILuY0yiiyDwSm2g441r6rMSgp6iK42yqroI2QoXeJVeA+YeZSa47gZdXaZWQKTrG93rukk/l2Al6Kzh5AZEl7dDQy+JjgFahQjRopSxPbrbvK7GRe9ePWBo1wcU7sYrFZtavXALwGw/7Dnc50urrHJuTPSoO2IMV3gUQGNg87IbSOIY9BpiT9HV7FCZ94nPXb3MSnwHn/FFFE1vG6DTby+r31KAkUktB3Qf6ikUPWxW1BkXSPQeMHHiW0+HAd2GelJsZz1OJegCxqzl+CLVHa/IibuHeJ1HAKzhuDR+ymNaRFM+4jU6UWKXorRmbyqkq/D76FffevwdCp+jN3UAN/C9JRVTDuOxC/oh+EdMnqIOrlYteKSfadVRGLJFJPSB/ti/6K8f0CNymg/iH2gO/f0DwE0yjAFO6l8JaR5j0VPwPwfaYHqOqrCI319WzwhwzNW/aQAAAABJRU5ErkJggg==',
        className: 'important-node'
      }
]);

观察b站的流程图,我们发现需要拖进画图14个矩形节点,大概拖进来后长这样子,接下来我们通过调用实例上的方法lf.getGraphData()即可获取到当前画布的数据保存下来,以免刷新丢失。

可以在画布外设置一个按钮,调用获取图数据,做实时保存。保存下来的数据大概长这样:

export const groupData = {
  nodes: [
    { id: '87692456-fc48-4b0e-b7d8-b5bb79822ae5', type: 'rect', x: 308, y: 74, properties: {} },
    { id: 'f3650805-cb9e-4e0e-aa74-1ae1de198bb3', type: 'rect', x: 304, y: 148, properties: {} },
    { id: 'a6e4643d-6131-43e2-9c7f-480c531192b7', type: 'rect', x: 312, y: 264, properties: {} },
    { id: '2a58edac-442c-47d2-a3bd-77767c17b26f', type: 'rect', x: 316, y: 340, properties: {} },
    { id: 'ce97cc95-882f-449e-83d5-e5e0141eadd4', type: 'rect', x: 304, y: 456, properties: {} },
    { id: 'd4bb5170-be9d-4000-a8bf-2b2b6d0804a6', type: 'rect', x: 308, y: 535, properties: {} },
    { id: '834a95a3-3637-44b8-b5c9-7fc8d7e50c87', type: 'rect', x: 313, y: 634, properties: {} },
    { id: 'd68757e6-24d4-4ab4-94cd-ab131f9b788a', type: 'rect', x: 304, y: 710, properties: {} },
    { id: 'cc1cde7f-daad-4d04-9cad-8ed8d406a8be', type: 'rect', x: 557, y: 263, properties: {} },
    { id: '9934569e-cdb2-48b9-9b3a-c4de39832b27', type: 'rect', x: 556, y: 336, properties: {} },
    { id: 'e7e87057-6e31-4945-aec2-a547308e1de8', type: 'rect', x: 555, y: 457, properties: {} },
    { id: '089384e0-a0ab-489a-aa26-c7c4e8b4610f', type: 'rect', x: 559, y: 536, properties: {} },
    { id: 'be77ef9e-0b9d-41a8-86d7-b29b41160f77', type: 'rect', x: 817, y: 358, properties: {} },
    { id: '393b8aee-11a0-4007-a38f-5f0dde2e2526', type: 'rect', x: 821, y: 442, properties: {} }
  ],
  edges: []
};

这种数据格式是lf底层渲染规范,各个参数大概意思是:

  • id,每个节点/连线独一无二的身份id
  • type, lf内置的的节点/边类型
  • x,y,遵循svg的坐标规范,为图形左上角的坐标。注意和下文中从model去取的x,y不同,model的x,y是图形中心的坐标

然后用 lf.render(groupData)渲染这部分数据,这样我们基本框架就算完成了。

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

自定义节点

由于b站的流程图节点长这样子,现在的样式和功能还没达到我们要求,接下来我们先“包装加工”下我们的节点

logicflow通过lf.register注册的形式来注册自己的节点,我们创建一个自定义节点文件(叫TBD-node),根据继续看logicflow规则,一个节点需要包含Model类和View类,这两个类的作用:

  • Model类 在model中通过getNodeStyle()钩子函数,重新节点样式,这个函数在节点数据properties改变时会触发执行。

  • View类 在view中,我们可以在钩子函数getShape中,通过调用lg提供的h函数(类似vue的createlement函数),来自定义渲染我们的节点svg dom。

这里需要注意lf的底层设计:

虽然自定义节点view优先级最高,功能也最完善,理论上我们可以完全通过自定义节点view实现任何我们想要的效果,但是此方式还是存在一些限制。

  1. 自定义节点view最终生成的图形的形状属性必须和model中形状属性的一致,因为节点的锚点、外边框都是基于节点model中的width和height生成。

  2. 自定义节点view最终生成的图形整体轮廓必须和继承的基础图形一致,不能继承的rect而在getShape的时候返回的最终图形轮廓变成了圆形。因为LogicFlow对于节点上的连线调整、锚点生成等会基于基础图形进行计算。

重写节点样式

我们在第一步拿到的画布数据基础上,给节点数据的properties属性增加'win'和'lose'字段,用来区分b站流程图中两种节点的样式。并通过重写getNodeStyle函数自定义节点样式:

  /**
   * 重写节点样式
   */
  getNodeStyle() {
    const style = super.getNodeStyle();
    const { result } = this.properties;
    if (result === 'win') {
      style.fill = "#0094ff"
    } else {
      style.fill = "#f1f2f3"
    }
    style.stroke = '#EAEAEC'
    style.strokeWidth = 1;
    return style;
  }

fill, stroke都是svg的基础属性,可以参考MDN

重写节点图形

观察b站流程图节点样式,我们发现除了主要的矩形节点,还有右边一个阴影矩形,左边一个图标,一个队伍文本,一个比分文本,通过自定义绘制image,rect,text 三种svg即可:

这里主要注意的rect中的x,y是svg标准规范的左上角,model中的x,y是图形中心的坐标,从model层读取坐标数据后在view层要计算一下

  getShape() {
    const {
      text,
      x,
      y,
      width,
      height,
      radius
    } = this.props.model;
    
  // 省略一些样式判断代码
  
    return h(
      'g',
      {
        className: 'lf-TBD-node',
      },
      [
        h('rect', {
          ...style,
          x: x - width / 2,
          y: y - height / 2,
          width,
          height,
          rx: radius,
          ry: radius
        }),
        h('g', {
          style: 'pointer-events: none;',
          transform: `translate(${x}, ${y})`
        }, [
          h('rect', {
            x: width/2-26 ,
            y:  -22,
            width: 26,
            height: 44,
            fill: '#000',
            stroke: 'none',
            ...scoreBack
          }),
          h('text', {
            x: width/2-18 ,
            y: 5,
            style: scoreTextStyle
          }, [score]),
          h('text', {
            x: -80 ,
            y: 5,
            style: teamNameTextStyle
          },[name]),
          h('image', {
            width: 35,
            height: 35,
            x: - width / 2 + 3,
            y: - height / 2 + 3,
            href: getIcon(name)
          })
        ])
      ]
    )
  }

在lg中的view类中, model的所有属性都通过props形式传入,取值同理,通过自定义属性properties来判断给win的队伍节点和lose的队伍节点设置不同样式。 「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

代码:

  // 上面代码片段中省略的样式代码
  const style = this.props.model.getNodeStyle()
    const score = this.props.model.properties.score || 0;
    const { result, name } = this.props.model.properties;
    let scoreTextStyle = '', teamNameTextStyle = '', scoreBack = {};
    if (result === 'win') {
      teamNameTextStyle = "fill:#fff;";
      scoreTextStyle = "fill: #fff;";
      scoreBack = {
        fill : 'rgb(66,49,49)',
        fillOpacity: 0.3,
      }
    } else {
      teamNameTextStyle = 'fill: #9499a0;';
      scoreTextStyle = 'fill: #9499a0;';
      scoreBack = {
        fillOpacity: 0.1,
      }
    }
    teamNameTextStyle += "font-size: 14px;font-family: Helvetica, Arial, sans-serif;text-overflow: ellipsis;letter-spacing: 0;"
    scoreTextStyle += "font-size: 18px;font-family: Helvetica, Arial, sans-serif;"

自定义线

和自定义节点相似,lg也可以自定义线的样式和功能,首先我们先继承lg内置的Polyline折线类型。为了模仿b站流程图中线的样子,我们要在Model层的getEdgeStyle()方法中重写线的样式,在view类中的getArrow()方法中将线的箭头视图隐藏掉。

代码:

import { PolylineEdge, PolylineEdgeModel } from '@logicflow/core';

class BetterLineModel extends PolylineEdgeModel {
  getEdgeStyle() {
    const style = super.getEdgeStyle();
    style.stroke = '#9499a0';
    style.strokeWidth = 1;
    style.strokeLinecap = 'butt';
    style.strokeLinejoin = 'miter';
    style.fill = 'transparent';
    return style;
  }
}

class BetterLine extends PolylineEdge {
  getArrow() {
    return null;
  }
}

export default {
  type: 'better-line',
  view: BetterLine,
  model: BetterLineModel
};

封装插件

lf 提供了插件机制来实现封装和复用,比如上文中我们实现的b站风格的流程图队伍节点和连线,如果其他页面的流程图也想复用,就需要我们把这个整体封装成一个lf的plugin。

lf的plugin规范也很简单,声明一个plugin类,在这个类中注册你的节点和线,默认导出即可:

封装插件:

import TBDNode from './TBD-node';
import betterLine from './better-line';
import type { LogicFlow } from '@logicflow/core';


class S12Plugin {
  static pluginName = 's12';
  lf: LogicFlow;
  constructor({ lf }) {
    this.lf = lf;
    this.lf.register(TBDNode);
    this.lf.register(betterLine);
  }
}
export {
  S12Plugin,
};

使用插件:

    const lf = new LogicFlow({
      container: this.$refs.container,
      width: 1300,
      height: 700,
      plugins: [ S12Plugin]
    });

微调

比对现在效果,我们还需要微调一些样式。 一个是隐藏锚点,lf每个节点默认有4个锚点(出线的地方),分别在矩形节点的四个边中间,我们点击锚点可以拉出logicflow的连线出来,链接到目标节点的锚点上,连接完线后我们不需要再显示锚点。

重写锚点样式在model层重写getAnchorStyle()方法:

  getAnchorStyle (anchorInfo) {
    const style = super.getAnchorStyle(anchorInfo);
    style.fill = 'transparent';
    style.stroke = 'transparent';
    style.hover.fill = 'transparent';
    style.hover.stroke = 'transparent';
    return style;
  }

第二个是微调数据,拖拽毕竟不能保证两条线完全重合,比如:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的? 在数据中找到其中一条线,如下图,pointList就是折线的从起点开始包含拐角点到终点的所有点坐标集合,微调其中的y坐标数据即可。 「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

微调后,锚点和线都完整重合啦:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

至此,我们已经实现了仿b站赛事专题页英雄联盟专题的一个队伍晋级流程图了:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

完整代码:github

预览地址: 预览

继续思考 & 加需求

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

问题:如果我们在做的队伍晋级流程图需求面向的是正在进行的比赛,如下图,很多晋级队伍位置还是TBD状态(如上图)。产品小姐姐要求我们比赛结果一出来,就能马上更新流程图新的状态。

lf的UI是靠画图数据驱动,我们只要能快速生成对应的节点线数据,就可以快速更新流程图的UI。

在本文上面最开始的初始化章节中,我们就是通过拖拽生成节点,然后调用全局保存图数据的方式,初始化了基础框架。同理,针对上诉需求,我们可以在后台做一个拖拽配置功能,如:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

这这个图中,我们按照上面的自定义节点方式,又实现了左侧的team-node节点,我们的需求是希望拖动左侧队伍节点到右侧流程图TBD节点时,能把TBD节点换成新的队伍节点,位置不变。

这样我们再调用获取图数据按钮,就可以拿到新的数据,再发布更新到线上,就可以满足产品小姐姐快速更新流程图结果的需求啦。

设计实现

查看lf文档可知链接,lf提供了丰富的各种当前流程图发生的事件,我们需要的事件有:

  • node:dnd-drag 外部拖入节点添加时触发
  • node:dnd-add 外部拖入节点添加时触发

思路设计:当拖拽左边team-node时,利用dnd-drag不断判断team-node节点是否靠近TBD节点,靠近时,TBD节点高亮,表示已到可以更新TBD节点的状态,同时触发node:dnd-add事件,我们在该事件中删除team-node节点,同时拿到team-node节点的数据(队伍名,图标等),更新TBD节点即可

大概实现:

import TeamNode from './team-node';
import betterLine from './better-line';
import type { LogicFlow } from '@logicflow/core';
import debounce from 'lodash.debounce';

class S12Plugin {
  static pluginName = 's12';
  lf: LogicFlow;
  constructor({ lf }) {
    this.lf = lf;
    this.lf.register(TeamNode);
    this.lf.register(betterLine);
    this.lf.on('node:dnd-drag', debounce(this.checkAppendBoundaryEvent, 10));
    this.lf.on('node:dnd-add', this.appendBoundaryEvent);

  }
  // 如果拖拽到节点内,更新TBD节点数据,同时删除拖动的team-node节点
  private appendBoundaryEvent = ({ data }) => {
    console.log('node:dnd-add')
    const closeNodeId = this.checkAppendBoundaryEvent({ data })
    if (closeNodeId) {
      const nodeModel = this.lf.graphModel.getNodeModelById(closeNodeId)
      nodeModel.setIsCloseToBoundary(false)
      nodeModel.text.value = data.text.value
      nodeModel.setProperties(data.properties)
    }
    this.lf.deleteNode(data.id)
  }
  // 检测拖拽节点时,有没有拖拽到TBD节点内
  private checkAppendBoundaryEvent = ({ data }) => {
    const { x, y, id } = data;
    const { nodes } = this.lf.graphModel;
    let closeNodeId = '';
    for (let i = 0; i < nodes.length; i++) {
      const nodeModel = nodes[i];
      if (nodeModel.id !== id && nodeModel.isTeamNode) {
        if (this.isCloseNodeEdge(nodeModel, x, y) && !closeNodeId) { // 同时只允许在一个节点的边界上
          nodeModel.setIsCloseToBoundary(true);
          closeNodeId = nodeModel.id;
        } else {
          nodeModel.setIsCloseToBoundary(false);
        }
      }
    }
    return closeNodeId;
  }
  // 判断是否这两个节点靠近
  private isCloseNodeEdge (nodeModel, x, y) {
    if (Math.abs(nodeModel.x - x) < 30 && Math.abs(nodeModel.y - y) < 10) {
      return true
    }
    return false
  }
}
export {
  S12Plugin,
  TeamNode,
};

  //team-node.ts
  /**
   * 提供方法给插件在判断此节点被拖动边界事件节点靠近时调用,从而触发高亮
   */
  setIsCloseToBoundary (flag) {
    // 每次setProperty更改Property属性时,lf内部会重新re-render
    this.setProperty('isCloseToBoundary', flag)
  }

效果:

「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?

这个功能代码和上面b站不在一版,代码地址链接

大家有什么问题,欢迎下方留言一起探讨。

附录logicflow github地址