一、背景介绍
大家好,今天分享下这个流程图效果的实现,帮助有需要的同学,依赖框架 - antv x6, 功能如下
- 流程图框架,x6
- 流动线效果,自定义线样式
- 自定义react节点
- x6的布局库
二、demo页面
既然能看这篇文档,说明大家都有点前端依赖安装基础的,依赖请务必自行安装,参考版本如下:
"@ant-design/icons": "^5.3.7",
"@antv/x6": "^2.18.1",
"@antv/x6-plugin-dnd": "^2.1.1",
"@antv/x6-plugin-history": "^2.2.4",
"@antv/x6-plugin-minimap": "^2.0.7",
"@antv/x6-plugin-scroller": "^2.0.10",
"@antv/x6-plugin-selection": "^2.2.2",
"@antv/x6-plugin-snapline": "^2.1.7",
"@antv/x6-react-components": "^2.0.8",
"@antv/x6-react-shape": "^2.2.3",
"@umijs/max": "^4.2.5",
"antd": "^5.17.4",
"dagre": "^0.8.5",
import { ProCard } from '@ant-design/pro-components';
import { useState } from 'react';
import GraphPanel from './graph-panel';
const dataLayout = {
nodes: [
{
id: '1',
shape: 'rect',
label: '开始',
data: {
status: 'success',
},
},
{
id: '2',
shape: 'rect',
label: '处理',
data: {
status: 'success',
},
},
{
id: '3',
shape: 'rect',
label: '老王复核',
data: {
status: 'success',
},
},
{
id: '4',
shape: 'rect',
label: '老李复核',
data: {
status: 'success',
},
},
{
id: '5',
shape: 'rect',
label: '抽查',
data: {
status: 'processing',
},
},
{
id: '6',
shape: 'rect',
label: '审批',
data: {
status: 'processing',
},
},
{
id: '7',
shape: 'rect',
label: '完成',
data: {
status: 'init',
},
},
],
edges: [
{
shape: 'edge',
source: '1',
target: '2',
},
{
shape: 'edge',
source: '2',
target: '3',
},
{
shape: 'edge',
source: '2',
target: '4',
},
{
shape: 'edge',
source: '2',
target: '5',
},
{
shape: 'edge',
source: '3',
target: '6',
},
{
shape: 'edge',
source: '4',
target: '6',
},
{
shape: 'edge',
source: '6',
target: '7',
},
{
shape: 'edge',
source: '5',
target: '7',
},
],
};
const nodeMap: Record<string, any> = {};
dataLayout.nodes = dataLayout.nodes.map((nodeItem) => {
// 记录节点id到节点的映射
nodeMap[nodeItem.id] = nodeItem;
// 处理节点的宽度,根据节点的名字匹配暂定单字符16单位宽度
let width = nodeItem.label?.length * 16;
if (nodeItem.label?.length <= 2) {
width = 40;
}
return {
...nodeItem,
width: width,
height: 16,
shape: 'custom-react-node', // 增加shape用于渲染自定义react组件
};
});
dataLayout.edges = dataLayout.edges.map((item) => {
const sourceNode = nodeMap[item.source];
const targetNode = nodeMap[item.target];
if (!sourceNode || !targetNode) {
return item;
}
let lineStyle = {
stroke: 'green', // 线条颜色
strokeWidth: 1, // 线条宽度
};
if (targetNode.data.status === 'processing') {
lineStyle = {
stroke: 'brown', // 线条颜色
strokeWidth: 1, // 线条宽度
strokeDasharray: '2 2', // 虚线样式设置
style: {
animation: 'flowing-line 10s infinite linear', // 应用动画
},
};
}
if (targetNode.data.status === 'init') {
// 边的结束节点,如果是处理中,则这条边是虚线样式
lineStyle = {
...lineStyle,
stroke: '#b7b9c1',
};
}
return {
...item,
attrs: {
line: {
...lineStyle,
targetMarker: {
name: 'classic', // 箭头类型,可以是 'block', 'classic' 等
size: 4, // 调整箭头大小,默认是 10
width: 4, // 箭头宽度
height: 6, // 箭头高度
},
},
},
};
});
const Progress = () => {
const [nodes] = useState(dataLayout.nodes);
const [edges] = useState(dataLayout.edges);
return (
<div>
<ProCard>
<div>流程进度bysking</div>
<div>
<GraphPanel nodes={nodes} edges={edges} />
</div>
</ProCard>
</div>
);
};
export default Progress;
三、graph-panel
import { layoutGraph } from '@/pages/Table/graph-tool';
import { Graph } from '@antv/x6';
import { Scroller } from '@antv/x6-plugin-scroller';
import { Snapline } from '@antv/x6-plugin-snapline';
import { register } from '@antv/x6-react-shape';
import { useEffect, useRef } from 'react';
import CustomReactNode from './custom-react-node';
import './index.less';
type typeProps = {
nodes: any;
edges: any;
};
register({
shape: 'custom-react-node',
effect: ['data'],
component: CustomReactNode,
});
const GraphPanel = (props: typeProps) => {
const refContainer = useRef();
const graphIns = useRef<Graph>();
const scrollerIns = useRef<Scroller>();
const { nodes, edges } = props;
const initPlugins = (graph: Graph) => {
graph.use(
new Snapline({
enabled: true,
}),
);
scrollerIns.current?.dispose();
// scrollerIns.current = new Scroller({
// enabled: true,
// // pageVisible: false,
// pageBreak: true,
// pannable: false,
// autoResize: true,
// autoResizeOptions: {
// border: 200,
// },
// });
// graph.use(scrollerIns.current);
};
const init = () => {
graphIns.current = new Graph({
interacting: false, // 禁用所有交互
container: refContainer.current,
autoResize: true,
panning: false,
virtual: false,
mousewheel: false,
// 设置画布背景颜色
background: {
// color: '#F2F7FA',
},
// 设置画布缩放阈值
// scaling: {
// min: 0.1,
// max: 3,
// },
grid: {
visible: false,
type: 'doubleMesh',
args: [
{
color: '#eee', // 主网格线颜色
thickness: 1, // 主网格线宽度
},
{
color: '#ddd', // 次网格线颜色
thickness: 1, // 次网格线宽度
factor: 2, // 主次网格线间隔
},
],
},
connecting: {
connectionPoint: {
name: 'boundary',
args: {
offset: 2,
},
},
router: {
name: 'manhattan',
args: {
args: {
padding: 20,
step: 10,
startDirections: ['right'],
endDirections: ['left'],
},
},
},
connector: {
name: 'rounded',
args: {
radius: 10,
},
},
},
});
initPlugins(graphIns.current);
graphIns.current?.fromJSON({
nodes,
edges,
}); // 渲染默认节点数据
setTimeout(() => {
// 自动左右布局
layoutGraph(graphIns.current as Graph, 'LR', 150, 30);
resizeGraph();
}, 100);
};
const resizeGraph = () => {
// 更新画布大小
const container = document.getElementById('container');
if (!container || !graphIns.current) {
return;
}
graphIns.current.resize(container.clientWidth, container.clientHeight);
// 重新缩放适应
graphIns.current.zoomToFit({ padding: 30 });
};
useEffect(() => {
init();
}, [props.edges, props.nodes]);
useEffect(() => {
// 监听窗口大小变化
window.addEventListener('resize', () => {
resizeGraph();
});
}, []);
return (
<div style={{ border: '1px solid rgba(5,5,5,0.05)' }}>
<div style={{ height: '300px' }} id="container">
<div ref={refContainer} />
</div>
</div>
);
};
export default GraphPanel;
graph-panel/tool.ts
import { Graph } from '@antv/x6';
import dagre from 'dagre';
export const layoutGraph = (
graph: Graph,
dir: 'LR' | 'RL' | 'TB' | 'BT' = 'LR',
width = 140,
height = 30,
) => {
const nodes = graph.getNodes();
const edges = graph.getEdges();
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: dir, nodesep: 16, ranksep: 16 });
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
g.setNode(node.id, {
width: node.width || width,
height: node.height || height,
});
});
edges.forEach((edge) => {
const source = edge.getSource();
const target = edge.getTarget();
g.setEdge(source.cell, target.cell);
});
dagre.layout(g);
g.nodes().forEach((id) => {
const node = graph.getCellById(id) as Node;
if (node) {
const pos = g.node(id);
node.position(pos.x, pos.y);
}
});
return; // 暂时不用下面的布局 todo
edges.forEach((edge) => {
const source = edge.getSourceNode()!;
const target = edge.getTargetNode()!;
const sourceBBox = source.getBBox();
const targetBBox = target.getBBox();
if ((dir === 'LR' || dir === 'RL') && sourceBBox.y !== targetBBox.y) {
const gap =
dir === 'LR'
? targetBBox.x - sourceBBox.x - sourceBBox.width
: -sourceBBox.x + targetBBox.x + targetBBox.width;
const fix = dir === 'LR' ? sourceBBox.width : 0;
const x = sourceBBox.x + fix + gap / 2;
edge.setVertices([
{ x, y: sourceBBox.center.y },
{ x, y: targetBBox.center.y },
]);
} else if (
(dir === 'TB' || dir === 'BT') &&
sourceBBox.x !== targetBBox.x
) {
const gap =
dir === 'TB'
? targetBBox.y - sourceBBox.y - sourceBBox.height
: -sourceBBox.y + targetBBox.y + targetBBox.height;
const fix = dir === 'TB' ? sourceBBox.height : 0;
const y = sourceBBox.y + fix + gap / 2;
edge.setVertices([
{ x: sourceBBox.center.x, y },
{ x: targetBBox.center.x, y },
]);
} else {
edge.setVertices([]);
}
});
};
index.less
@keyframes ant-line {
to {
stroke-dashoffset: -1000
}
}
custome-react-node.tsx
import {
CheckCircleTwoTone,
Loading3QuartersOutlined,
} from '@ant-design/icons';
import { Node } from '@antv/x6';
import { useEffect, useState } from 'react';
const CustomReactNode = ({ node, ...rr }: { node: Node }) => {
const label = node?.store?.data?.label;
const [nodeData, setData] = useState(node.getData() || {});
const onDataChanged = () => {
setData(node.getData());
};
useEffect(() => {
/** 监听节点变化事件 */
node.on('change:data', onDataChanged);
return () => {
/** 销毁事件 */
node.off('change:data', onDataChanged);
};
}, []);
const renderStatus = () => {
let iconMap = {
success: (
<CheckCircleTwoTone
size={12}
color="#694093"
style={{ color: '#694093' }}
/>
),
processing: (
<Loading3QuartersOutlined
spin
style={{
color: '#694093',
}}
/>
),
init: (
<div
style={{
width: '16px',
height: '16px',
border: '1px solid #b7b9c1',
borderRadius: '50%',
}}
></div>
),
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
{iconMap[nodeData?.status]}
</div>
);
};
let bColor = nodeData?.status === 'processing' ? 'green' : 'rgba(5,5,5,0.2)';
let isInit = nodeData?.status === 'init';
return (
<div
style={{
// border: `1px solid ${bColor}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'left',
borderRadius: '4px',
gap: '4px',
}}
>
{renderStatus()}
<div
style={{
color: isInit ? '#757a89' : '',
fontSize: '10px',
}}
>
{label}
</div>
</div>
);
};
export default CustomReactNode;
结语
路过不加个关注再走?持续分享本人亲身经历的业务场景的一些实践,希望对你有帮助