「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?
「bilibili赛事专题页」中的英雄联盟「晋级流程图」是如何实现的?
前言:这是一篇面向没接触过流程图实现的小白群体的文章,所以不会有较难懂的原理的东西,看完你就会使用一套图流程引擎框架来实现一些流程图需求,
晋级流程图是什么?
在刚刚过去的英雄联盟S12中,可能大家还沉浸在中国队痛失决赛的悲痛中,洗把脸,日子还是要过,跟着我,来学习一点新技术~
在各个直播平台中,我们肯定见过这样的队伍晋级图:
来自维基百科的图
来自b站直播页的图
现在打开b站赛事专题页,我们仍可以看到这样的流程图:
如果我们来自己实现,看看如何用前端技术,来实现这样的 "流程图"。
技术调研和选型
分析
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文档:http://logic-flow.org/
github地址:https://github.com/didi/LogicFlow (点点star,鼓励开发者继续维护升级)
LogicFlow 是一款开源的流程图编辑框架,提供了一系列流程图交互、编辑所必需的功能和灵活的节点自定义、插件等拓展机制。LogicFlow支持前端研发自定义开发各种逻辑编排场景,如流程图、ER图、BPMN流程等。在工作审批配置、机器人逻辑编排、无代码平台流程配置都有较好的应用。
为了方便,以下logicflow简称lf。
实现思路
大概阅读过一遍logicflow的文档后,对实现b站这样一个流程图心里有了一个大概思路:
- 动态创建几个方形节点。
- 节点自定义编码,做成b站流程图节点的样子。
- 拖拽这些节点到合适的位置,调用logicflow的全局获取数据方法,保存到代码里。
- 拖拽节点之间连线,自定义连线样式,调用logicflow的全局获取数据方法,保存到代码里。
- 微调节点和连线的数据坐标,保证绝对对齐和一些重合效果等。
- ....(其他扩展功能)
编码
初始化
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)
渲染这部分数据,这样我们基本框架就算完成了。
自定义节点
由于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实现任何我们想要的效果,但是此方式还是存在一些限制。
自定义节点view最终生成的图形的形状属性必须和model中形状属性的一致,因为节点的锚点、外边框都是基于节点model中的width和height生成。
自定义节点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的队伍节点设置不同样式。
代码:
// 上面代码片段中省略的样式代码
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;
}
第二个是微调数据,拖拽毕竟不能保证两条线完全重合,比如:
在数据中找到其中一条线,如下图,pointList就是折线的从起点开始包含拐角点到终点的所有点坐标集合,微调其中的y坐标数据即可。微调后,锚点和线都完整重合啦:
至此,我们已经实现了仿b站赛事专题页英雄联盟专题的一个队伍晋级流程图了:
完整代码:github
预览地址:
预览
继续思考 & 加需求
问题:如果我们在做的队伍晋级流程图需求面向的是正在进行的比赛,如下图,很多晋级队伍位置还是TBD状态(如上图)。产品小姐姐要求我们比赛结果一出来,就能马上更新流程图新的状态。
lf的UI是靠画图数据驱动,我们只要能快速生成对应的节点线数据,就可以快速更新流程图的UI。
在本文上面最开始的初始化
章节中,我们就是通过拖拽生成节点,然后调用全局保存图数据的方式,初始化了基础框架。同理,针对上诉需求,我们可以在后台做一个拖拽配置功能,如:
这这个图中,我们按照上面的自定义节点方式,又实现了左侧的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)
}
效果:
这个功能代码和上面b站不在一版,代码地址链接
大家有什么问题,欢迎下方留言一起探讨。