使用g6(antdV-g6)实现简单的逻辑脑图(react、G6、antdV)

使用g6(antdV-g6)实现简单的逻辑脑图

现在我们有一个需求:要求我们把相互关联的数据实现成脑图或者是树图;

1.需求分析

那么我们要实现这个功能,首先我们应该干什么?

第一步当然是思考怎么能把我们的数据实现成脑图,既然我们要画脑图,那么我们必然会需要我们学过的canvas或者是svg。

那我们,光知道这两个东西能实现画脑图,我们要怎么把它实现出来能?这就会有许多的问题会等待着我们去解决。。。

比如怎么画一个长方形,怎么画一个正方形,怎么画一条线,怎么让一条线连着有关联的点,等等一系列的问题。所以我们为何不找找现成的东西,拿来用,何不快哉!

2.调研

所以什么样的组件能帮我们实现这个功能呢?

2.1relation-graph

官网地址:

relation-graph

这个确实能实现我们的需求,但是出处不明,最后我还是选择了大厂的。这里没有贬低的意思,而是产品中的需求需要一个更稳定更新的产品。

2.2AntV-G6&&AntV-X6

官网地址:

G6

官网地址:

X6

这里为什么写了两个?如果你看过这两个东西,你会发现,他们是在实现一个东西,只不过使用的技术不太一样,一个是canvas,一个是svg。

这里如果想更多的了解他两的区别,不妨去看看参与研发的人员的自述吧:https://www.zhihu.com/question/435855401,如果链接过期了,不妨看看下面的图片吧!

 

好啦好啦,正题开始吧!

3.实现

3.1阅读官网

首先你需要花费半天或者一天的时间,好好去官网去阅读一下,这个东西应该怎么用,不然你怎能去实现你的功能。

3.2官网的第一个简单的例子

import G6 from "@antv/g6";

const data = {
  nodes: [
    {
      id: "node1",
      label: "Circle1",
      x: 150,
      y: 150
    },
    {
      id: "node2",
      label: "Circle2",
      x: 400,
      y: 150
    }
  ],
  edges: [
    {
      source: "node1",
      target: "node2"
    }
  ]
};

const graph = new G6.Graph({
  container: "container",
  width: 500,
  height: 500,
  defaultNode: {
    type: "circle",
    size: [100],
    color: "#5B8FF9",
    style: {
      fill: "#9EC9FF",
      lineWidth: 3
    },
    labelCfg: {
      style: {
        fill: "#fff",
        fontSize: 20
      }
    }
  },
  defaultEdge: {
    style: {
      stroke: "#e2e2e2"
    }
  }
});

graph.data(data);
graph.render();

3.3如何安装

 npm install --save @antv/g6

3.4拆件

Atlas-->index.js

import React, { useEffect, useRef, useState } from 'react';
import G6, { Graph, } from '@antv/g6';
import { Checkbox, Input, message, Space, Spin } from 'antd';
import IconBlood from './icon-blood-blue.png';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import BigNumber from 'bignumber.js';
import { legendData, options } from './variable';
import { fittingString, getNodeConfig } from './toolFunc';
import './index.less';
import PropTypes from 'prop-types';
import { fetchApi } from 'utils';
import { api } from '../../config';

const pixClassName = 'atlas';
let graph = '';
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10;
let primaryKey = '';
const Atlas = (props) => {
	const { edges, nodes, selectedNodeId, setTitle, dataType, selectedPrimaryKey } = props;
	const atlas = useRef(null);
	const containerRef = useRef(null);
	const graphRef = useRef(null);
	// 当前选中复选框值
	const [check, setCheck] = useState(['01', '02']);
	const [zoom, setZoom] = useState(0);
	// 选中的节点id
	const [bloodId, setBloodId] = useState(selectedNodeId);
	const [loading, setLoading] = useState(false);
	// 选中的节点类型
	const [checkType, setCheckType] = useState(dataType);
	const nodeBasicMethod = {
		createNodeBox: (group, config, w, h, info) => {
			const { id, dataType } = info;
			/* 最外面的大矩形 */
			const container = group.addShape('rect', {
				attrs: {
					x: 0,
					y: 0,
					width: w,
					height: h,
					fill: bloodId === id ? config.basicColor : '#fff',
					opacity: bloodId === id ? 0.15 : 0,
					cursor: 'pointer',
				},
				name: 'big-rect-shape',
			});
			// 矩形
			group.addShape('rect', {
				attrs: {
					x: 0,
					y: 0,
					width: w,
					height: h,
					stroke: '#D9D9D9',
					radius: 2,
					cursor: 'pointer',
					lineDash: dataType !== 'effectNone' ? undefined : [4, 4]
				},
				name: 'rect-shape',
			});
			// 左边的粗线
			if (dataType !== 'effectNone') {
				group.addShape('rect', {
					attrs: {
						x: 0,
						y: 0,
						width: 3,
						height: h,
						fill: config.basicColor,
						radius: 2,
						cursor: 'pointer',
					},
					name: 'left-border-shape',
				});
			}
			return container;
		},
	};
	G6.registerNode('card-node', {
		draw: (cfg, group) => {
			const config = getNodeConfig(cfg);
			// 中间文字
			group.addShape('text', {
				attrs: {
					text: fittingString(cfg.label, 160, 10),
					x: 10,
					y: 16,
					fontSize: 10,
					fontWeight: 500,
					textAlign: 'left',
					textBaseline: 'middle',
					fill: config.fontColor,
					cursor: 'pointer',
				},
				name: 'name-text-shape',
			});
			// 判断是否为非数据源节点
			if (cfg.dataType !== 'source' && cfg.dataType !== 'effectNone') {
				group.addShape('image', {
					attrs: {
						x: 170,
						y: 7,
						height: 16,
						width: 16,
						img: IconBlood,
						cursor: 'pointer',
						opacity: 0,
					},
					name: 'icon-blood',
				});
			}
			return nodeBasicMethod.createNodeBox(group, config, 190, 30, cfg);
		},
		afterDraw: (cfg, group) => {
			// 获取图标节点
			const iconBox = group.find((element) => element.get('name') === 'big-rect-shape');
			const iconBlood = group.find((element) => element.get('name') === 'icon-blood');
			if (iconBlood && iconBox) {
				const iconBlood = group.find((element) => element.get('name') === 'icon-blood');
				iconBox.on('mouseenter', () => {
					iconBlood.attr('opacity', 1);
				});
				iconBox.on('mouseleave', () => {
					iconBlood.attr('opacity', 0);
				});
			}
		},
		getAnchorPoints: function getAnchorPoints() {
			return [
				[0, 0.5],
				[1, 0.5],
			];
		},
	});
	G6.registerEdge('dashed-line', {
		draw(cfg, group) {
			const startPoint = cfg.startPoint;
			const endPoint = cfg.endPoint;
			const { lineType } = cfg;
			const shape = group.addShape('path', {
				attrs: {
					path: [
						['M', startPoint.x, startPoint.y],
						['L', endPoint.x / 3 + (2 / 3) * startPoint.x, startPoint.y], // 控制点1
						['L', endPoint.x / 3 + (2 / 3) * startPoint.x, endPoint.y],   // 控制点2
						['L', endPoint.x, endPoint.y],
					],
					smooth: true, // 使用平滑的曲线
					stroke: '#999',
					lineWidth: 1,
					endArrow: true,
					lineDash: lineType === 'dashed-line' ? [4, 4] : undefined,
					style: { radius: 10 },
					name: `path-shape-${lineType}`,
				},
			});

			return shape;
		},
	});
	useEffect(() => {
		primaryKey = String(selectedPrimaryKey);
	}, []);
	useEffect(() => {
		const { clientWidth, clientHeight } = atlas.current;
		if (graphRef.current || !containerRef.current) return;
		const legend = new G6.Legend({
			data: legendData,
			align: 'center',
			layout: 'horizontal', // vertical
			position: 'bottom',
			horiSep: 20,
			containerStyle: {
				fill: '#fff',
				opacity: 0,
			}
		});
		graph = new Graph({
			container: containerRef.current,
			width: clientWidth,
			height: clientHeight - 30,
			fitCenter: true,
			defaultNode: {
				type: 'card-node',
			},
			defaultEdge: {
				type: 'dashed-line',
			},
			modes: {
				default: [
					'drag-canvas',
					{
						type: 'zoom-canvas',
						minZoom: MIN_ZOOM,
						maxZoom: MAX_ZOOM
					},
					'click-select',
					{
						type: 'tooltip',
						formatText(model) {
							const { label } = model;
							return label;
						},
						offset: 0,
					}
				]
			},
			plugins: [legend],
			layout: {
				type: 'dagre',
				direction: 'LR',
				rankdir: 'LR',
				nodesep: 50,
				ranksep: 80,
			},
		});

		// 绑定数据
		graph.data({
			edges,
			nodes
		});
		// 渲染图
		graph.render();
		graph.zoomTo(1);
		graphRef.current = graph;
		setZoom(graph.getZoom());
		graph.on('node:click', (evt) => {
			const { item: { _cfg: { model } } } = evt;
			if (model.dataType === 'source') {
				message.info('数据源不支持点击查看');
				return false;
			}
			if (model.dataType === 'effectNone') {
				message.info('无影响');
				return false;
			}
			if (primaryKey === String(model.primaryKey)) {
				message.info('已经处于该节点上,请勿重复点击');
				return false;
			}
			if (model.dataType !== 'source' && primaryKey !== String(model.primaryKey)) {
				setCheck(['01', '02']);
				primaryKey = String(model.primaryKey);
				setCheckType(model.dataType);
				getBloodData('node', model.primaryKey, model.dataType, true, true, model?.additionalData);
			}
		});
		graph.on('wheelzoom', e => {
			let newZoom = graph.getZoom();
			if (newZoom >= MAX_ZOOM) {
				message.info('最大放大10倍');
				newZoom = 10;
			} else if (newZoom <= MIN_ZOOM) {
				message.info('最小缩小10倍');
				newZoom = 0.1;
			}
			setZoom(newZoom);
		});
	}, []);
	const handleChange = (checkedValues) => {
		setCheck(checkedValues);
		const bloodAnalysis = checkedValues.indexOf('01') >= 0;
		const effectAnalysis = checkedValues.indexOf('02') >= 0;
		getBloodData('checkBox', primaryKey, checkType, bloodAnalysis, effectAnalysis);
	};
	const handleZoom = (type) => {
		let newZoom = zoom;
		if (type === 'add') {
			if (zoom >= MAX_ZOOM) {
				newZoom = MAX_ZOOM;
				message.info('最大放大10倍');
			} else if (zoom >= 1) {
				newZoom = Math.min(MAX_ZOOM, Math.floor(newZoom + 1));
			} else {
				newZoom = parseFloat((newZoom + 0.1).toFixed(1));
			}
		} else if (type === 'reduce') {
			if (zoom <= MIN_ZOOM) {
				newZoom = MIN_ZOOM;
				message.info('最小缩小10倍');
			} else if (zoom <= 1) {
				newZoom = Math.max(MIN_ZOOM, parseFloat((newZoom - 0.1).toFixed(1)));
			} else {
				newZoom = Math.max(1, Math.floor(newZoom - 1));
			}
		}
		setZoom(newZoom);
		graph.zoomTo(newZoom, { x: 100, y: 100 }, true);
	};
	const reduceIcon = () => {
		return (
			<MinusOutlined
				style={{
					cursor: 'pointer'
				}}
				onClick={() => {
					handleZoom('reduce');
				}}
			/>
		);
	};
	const addIcon = () => {
		return (
			<PlusOutlined
				style={{
					cursor: 'pointer'
				}}
				onClick={() => {
					handleZoom('add');
				}}
			/>
		);
	};
	const zoomForHundred = () => {
		return new BigNumber(zoom).multipliedBy(100).toFixed(0);
	};
	/**
	 * @description: 获取血缘分析数据
	 * @method: getBloodData
	 * @param: clickType: 点击类型。复选框:checkBox; 节点:node;
	 * @param: dataType: 节点类型。见propTypes中的dataType类型
	 * @param: bloodAnalysis: 血缘分析。true或者false
	 * @param: effectAnalysis: 影响分析。true或者false
	 * @param: additionalData: 附加信息。从后台数据中的节点获取对应字段内容
	 * @author: 上官靖宇
	 * @date: 2023/8/21
	 * @lastEditors: 上官靖宇
	 */
	const getBloodData = (clickType, primaryKey, dataType, bloodAnalysis, effectAnalysis, additionalData = null) => {
		setLoading(true);
		graph.clear();
		fetchApi({
			api: api.bloodDataList,
			data: {
				dataType: dataType,
				primaryKey: primaryKey,
				consanguinityAnalysis: bloodAnalysis,
				effectAnalysis,
				additionalData,
			},
			complete: () => {
				setLoading(false);
			},
			success: (res) => {
				const { title, edges, nodes } = res;
				setTitle(title);
				setBloodId(res.selectedNodeId);
				graph.data({
					edges,
					nodes
				});
				graph.render();
				graph.zoomTo(1);
				setZoom(1);
			},
			error: (err) => {
				setTitle('');
				const { errorDescription } = err;
				message.error(errorDescription || '请求失败');
			}
		});
	};
	return (
		<div className={pixClassName} ref={atlas}>
			{/*操作栏*/}
			<div className={`${pixClassName}-ope`}>
				<Space>
					<Checkbox.Group
						value={check}
						options={options}
						onChange={handleChange}
					/>
				</Space>
				<Space>
					<Input
						value={zoomForHundred()}
						style={{
							width: '110px'
						}}
						readOnly
						prefix={reduceIcon()}
						suffix={addIcon()}
					/>
				</Space>
			</div>
			<Spin spinning={loading} tip="数据更新中...">
				<div ref={containerRef} />
			</Spin>
		</div>
	);
};
Atlas.propTypes = {
	// 节点线信息
	edges: PropTypes.array,
	// 节点信息
	nodes: PropTypes.array,
	// 选中节点id,用于设置选中状态
	selectedNodeId: PropTypes.string,
	// 修改导航栏title
	setTitle: PropTypes.func,
	// 节点类型(数据类型: source:数据源; table:数据表; dataSet:数据集; dashboard;数据报表; chart:单图)
	dataType: PropTypes.string,
	// 选中节点primaryKey,用于后天提交参数
	selectedPrimaryKey: PropTypes.string
};
export default Atlas;

toolFunc.js

import {
	basicColorDataReport,
	basicColorDataSet,
	basicColorDataSheet,
	basicColorDataSingle,
	basicColorDataSource,
	fontColor
} from './variable';
import G6 from '@antv/g6';

const getNodeConfig = (node) => {
	// 默认数据源
	let config = {
		basicColor: basicColorDataSource,
		fontColor: fontColor,
		borderColor: basicColorDataSource,
	};
	switch (node.dataType) {
		case 'table': { // 数据表
			config = {
				basicColor: basicColorDataSheet,
				fontColor: fontColor,
				borderColor: basicColorDataSheet,
			};
			break;
		}
		case 'dataSet': { // 数据集
			config = {
				basicColor: basicColorDataSet,
				fontColor: fontColor,
				borderColor: basicColorDataSet,
			};
			break;
		}
		case 'chart': { // 单图
			config = {
				basicColor: basicColorDataSingle,
				fontColor: fontColor,
				borderColor: basicColorDataSingle,
			};
			break;
		}
		case 'dashboard': { // 数据报表
			config = {
				basicColor: basicColorDataReport,
				fontColor: fontColor,
				borderColor: basicColorDataReport,
			};
			break;
		}
		default:
			break;
	}
	return config;
};

/**
 * format the string
 * @param {string} str The origin string
 * @param {number} maxWidth max width
 * @param {number} fontSize font size
 * @return {string} the processed result
 */
const fittingString = (str, maxWidth, fontSize) => {
	const ellipsis = '...';
	const ellipsisLength = G6.Util.getTextSize(ellipsis, fontSize)[0];
	let currentWidth = 0;
	let res = str;
	const pattern = new RegExp('[\u4E00-\u9FA5]+'); // distinguish the Chinese charactors and letters
	str.split('').forEach((letter, i) => {
		if (currentWidth > maxWidth - ellipsisLength) return;
		if (pattern.test(letter)) {
			// Chinese charactors
			currentWidth += fontSize;
		} else {
			// get the width of single letter according to the fontSize
			currentWidth += G6.Util.getLetterWidth(letter, fontSize);
		}
		if (currentWidth > maxWidth - ellipsisLength) {
			res = `${str.substr(0, i)}${ellipsis}`;
		}
	});
	return res;
};

export {
	getNodeConfig,
	fittingString
};

variable.js

// 文字颜色
const fontColor = '#222';
const basicColorDataSource = '#5762EC';
const basicColorDataSheet = '#07A6F0';
const basicColorDataSet = '#39BF7C';
const basicColorDataSingle = '#F9BE0E';
const basicColorDataReport = '#FF892F';
const data = {
	nodes: [
		{
			id: '1',
			label: '园区标准数据集',
			dataType: 'dataSource',
		},
		{
			id: '2',
			label: 'dm_user_activity_launch_daily',
			dataType: 'dataSheet',
		},
		{
			id: '3',
			label: 'dim_user_user',
			dataType: 'dataSheet',
		},
		{
			id: '4',
			label: '用户活跃日表',
			dataType: 'dataSet',
		},
		{
			id: '5',
			label: '用户统计表',
			dataType: 'dataSingle',
		},
		{
			id: '6',
			label: '每日用户活跃统计',
			dataType: 'dataSingle',
		},
		{
			id: '7',
			label: '用户行为分析',
			dataType: 'dataReport',
		},
	],
	edges: [
		{
			source: '1',
			target: '2',
			lineType: 'solid-line'
		},
		{
			source: '1',
			target: '3',
			lineType: 'solid-line'
		},
		{
			source: '2',
			target: '4',
			lineType: 'solid-line'
		},
		{
			source: '3',
			target: '4',
			lineType: 'solid-line'
		},
		{
			source: '4',
			target: '5',
			lineType: 'dashed-line'
		},
		{
			source: '4',
			target: '6',
			lineType: 'dashed-line'
		},
		{
			source: '5',
			target: '7',
			lineType: 'dashed-line'
		},
		{
			source: '6',
			target: '7',
			lineType: 'dashed-line'
		},
	],
};
const legendData = {
	nodes: [{
		id: 'DataSource',
		label: '数据源',
		order: 0,
		style: {
			fill: basicColorDataSource,
		}
	}, {
		id: 'DataSheet',
		label: '数据表',
		order: 2,
		style: {
			fill: basicColorDataSheet,
		}
	}, {
		id: 'DataSet',
		label: '数据集',
		order: 3,
		style: {
			fill: basicColorDataSet,
		}
	}, {
		id: 'DataSingle',
		label: '单图',
		order: 4,
		style: {
			fill: basicColorDataSingle,
		}
	}, {
		id: 'DataReport',
		label: '数据报表',
		order: 5,
		style: {
			fill: basicColorDataReport,
		}
	}]
};
const options = [
	{ label: '血缘分析', value: '01' },
	{ label: '影响分析', value: '02' }
];
export {
	fontColor,
	basicColorDataSource,
	basicColorDataSheet,
	basicColorDataSet,
	basicColorDataSingle,
	basicColorDataReport,
	data,
	legendData,
	options
};

index.less

@charset "UTF-8";
/* @describe:
 * @author: 上官靖宇
 * @date: 2023/8/2 14:49
 */
@import '~styles/ant-prefix.less';
.atlas {
	width: 100%;
	height: 100%;
	box-sizing: border-box;
	overflow: hidden;
	&-ope {
		display: flex;
		justify-content: space-between;
		padding: 0 10px;
		height: 40px;
		line-height: 40px;
		.@{ant-prefix}-input {
			text-align: center;
		}
	}
}

.g6-tooltip {
	border-radius: 6px;
	font-size: 12px;
	color: #fff;
	background-color: #000;
	padding: 2px 8px;
	text-align: center;
}

icon-blood-blue.png只是个图标,大家可以自己随便放一个图片

实现如上图所示

后端数据格式参考

 

posted @ 2023-08-24 16:22  上官靖宇  阅读(2471)  评论(0编辑  收藏  举报