背景科普
随着公司业务扩大,数据日益复杂,当下非常需要一种对用户理解更简便、交互更友好的数据关系的可视化产品,围绕这个场景,本文带你深入浅出前端如何开发图可视化(不含树图)。
什么是图模型
图模型是一种用于表示对象之间关系的抽象数据结构。它由节点(Nodes)和边(Edges) 组成,节点代表对象,边表示节点之间的连接或关系。图模型可用于建模和分析各种复杂的关系型数据,如社交网络、知识图谱、地理数据等。 图模型具有以下特点:
- 节点:节点表示图中的对象或实体,可以携带属性和元数据来描述其特征。
- 边:边表示节点之间的关系,可以有方向性或无方向性。边也可以携带属性,用于描述关系的特性。
- 图遍历:通过遍历节点和边,可以在图中进行查询、分析和操作。
图常用的数据结构
在 antv 的 G6 中,图数据结构可以通过 JSON 格式定义。以下是一个示例
json
{
"nodes": [
{
"id": "node1",
"label": "Node 1"
},
{
"id": "node2",
"label": "Node 2"
}
],
"edges": [
{
"source": "node1",
"target": "node2",
"label": "Edge 1"
}
]
}
简单概括就是:需要节点和边两类JSONList,每个节点和边都可以定义自己的属性,比如 id、label 、name等。 一定要注意------Edge中的source和target连接的Node。
如果只想看前端实现部分,看完这里可以直接跳到技术探索
图相关的算法
图遍历介绍
图遍历是指通过遍历图中的节点,按照一定规则来发现或访问特定节点。在图遍历过程中需要记录已经访问的节点,以避免重复访问,通常使用栈或队列来辅助实现。
- 深度优先搜索(DFS):从起始节点开始,沿着路径尽可能地往深处搜索,直到无法继续前进时回溯,并继续搜索其他未访问的分支。
- 广度优先搜索(BFS):从起始节点开始,按层级逐步向外扩展,首先访问节点的所有邻居节点,再访问邻居节点的邻居节点,依此类推。
TypeScript实现DFS
ini
class Graph {
adjacencyList: Map<number, number[]>;
constructor() {
this.adjacencyList = new Map();
}
addVertex(vertex: number): void {
if (!this.adjacencyList.has(vertex)) {
this.adjacencyList.set(vertex, []);
}
}
addEdge(v1: number, v2: number): void {
if (this.adjacencyList.has(v1) && this.adjacencyList.has(v2)) {
this.adjacencyList.get(v1)?.push(v2);
this.adjacencyList.get(v2)?.push(v1);
}
}
dfs(startVertex: number): number[] {
const visited: number[] = [];
const stack: number[] = [];
stack.push(startVertex);
while (stack.length > 0) {
const currentVertex = stack.pop()!;
if (!visited.includes(currentVertex)) {
visited.push(currentVertex);
const neighbors = this.adjacencyList.get(currentVertex);
if (neighbors) {
for (let neighbor of neighbors) {
stack.push(neighbor);
}
}
}
}
return visited;
}
}
// 使用示例
const graph = new Graph();
graph.addVertex(1);
graph.addVertex(2);
graph.addVertex(3);
graph.addVertex(4);
graph.addEdge(1, 2);
graph.addEdge(2, 3);
graph.addEdge(3, 4);
const traversalResult = graph.dfs(1);
console.log(traversalResult); // 输出:[1, 2, 3, 4]
常用语查找路径、寻找连通分量、拓扑排序和生成最小生成树等。
最短路径算法 - Dijkstra算法
应用于网络路由、地图导航和最优路径规划等领域。它可以帮助找到图中两个节点间的最短路径。
聚类算法 - 谱聚类
常用于图像分割、社交网络分析和文本聚类等领域。它可以将数据点划分为不同的子集,每个子集代表一个聚类。
业务应用常见场景
- 社交网络分析:帮助揭示社交网络中的影响者、群体结构和信息传播路径,从而用于营销、推荐系统、舆情分析等领域。
- 金融风险管理:金融机构可以使用图数据来识别欺诈活动、异常交易和洗钱行为。通过将客户、交易和其他相关实体建模为图节点,并分析它们之间的关系和模式。
- 路径规划和物流优化:运输和物流公司可以使用图数据来优化路线规划、货物配送和资源利用,找到最佳路径,并减少运输成本和时间。
- 知识图谱和搜索引擎:图数据可以用于构建知识图谱,将实体和概念以节点的形式连接起来,用于开发智能搜索引擎、问答系统和推荐系统,提供更准确和个性化的搜索和推荐结果。
前端技术探索
市面上常见的可视化框架,在图分析场景的丰富性、二开复杂度antv比echarts更理想,因此采用antv体系。在调研G6的过程中发现有基于G6的二开框架------Graphin,下面是我们进行对比之后的结论。
antv G6 vs Graphin
G6
G6 使用 WebGL 技术,在渲染大规模关系图时表现出色, 提供了灵活的插件机制,可以扩展和定制特定需求的功能。学习起来api比较多,要花时间理解概念和使用方式,且是初始化实例的方式。
php
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
layout: {
type: 'random',
width: 300,
height: 300,
},
});
Graphin
Graphin 是基于 G6引擎 的图可视化工具(上层用react封装了一层),使得使用起来更加便捷。内置的可视化布局算法,更符合关系可视分析领域的解决方案。
javascript
import React from 'react';
import Graphin, { Utils, GraphinContext } from '@antv/graphin';
const data = Utils.mock(10)
.circle()
.graphin();
// 开放自定义注册组件的形式
const CustomComponents = () => {
// 只要包裹在Graphin内的组件,都可以通过Context获得Graphin提供的graph实例和apis
const { graph, apis } = React.useContext(GraphinContext);
return null;
};
const Demo = () => {
return (
<div className="App">
<Graphin data={data} ref={graphinRef}>
<CustomComponents />
</Graphin>
</div>
);
};
export default Demo;
提供以下选型原则考虑:
- 希望更自由地使用图形渲染引擎和自定义功能,可以选择 G6
- 希望简化开发流程并且采用 React 框架,那么选择 Graphin 会更加方便。
因为有二开业务组件和多人协作诉求,最后选择了Graphin。
基于Graphin二次开发
以下是翻过源码和一些issue之后的二开功能补充。
组件整体关系
ini
// 使用useContext向子组件共享api
<Context.Provider value={{ AtlasData: atlasData }}>
<Graphin
ref={graphRef}
data={atlasData as GraphinData}
theme={theme}
layout={{
...layout,
defSpringLen: defSpreingLen,
}}
defaultNode={defaultNode} // 节点的自定义默认配置
nodeStateStyles={defaultNodeStatusStyle} // 节点不同状态的样式配置
defaultEdge={defaultEdge} // 线的自定义默认配置
edgeStateStyles={defaultEdgeStatusStyle} // 线不同状态的样式配置
>
<InitRender /> // 初始化逻辑
<Hoverable bindType="node" />
<Hoverable bindType="edge" />
<ActivateRelations resetSelected trigger='click' />
<Tooltips /> // 自定义tooltip
<TooslBar direction="vertical"/> // 自定义toolsBar
// 事件系统
<SampleBehavior atlasData={atlasData} setAtlasData={setAtlasData} />
<FitView />
<DragNode />
<BrushSelect brushStyle={{ fill: '#1f7FD4', fillOpacity: 0.3 }} />
</Graphin>
</Context.Provider>
初始化-InitRender
在初始化里面做了一些图谱展示的业务逻辑,因为业务是全量渲染所以使用dfs自行做了收起逻辑
javascript
mport React, { useContext, useEffect } from 'react';
import {GraphinContext } from '@antv/graphin';
import { Context} from '../views/atlas';
import { dfsSecondChildrenAlllHide } from './dfs';
const InitRender = props => {
const { graph, apis } = useContext(GraphinContext);
const {AtlasData} = useContext(Context)
useEffect(() => {
// 聚焦
apis.focusNodeById('0');
// 初始化默认只展示二级节点
dfsSecondChildrenAlllHide(graph,AtlasData)
// 设置缩放比例大小
graph.setMaxZoom(1.5)
graph.setMinZoom(0.5)
graph.zoomTo(1); // 初始化大小
graph?.fitCenter(true); // 居中
return () => {
};
}, []);
return null;
};
export default InitRender;
Node特殊处理-Utils
文字过多换行处理
动态计算文字超出隐藏
ini
/**
* @param {string} str 待插入的字符(串)
* @param {object} size noede节点配置项
* @param {string} length 插入的字符内容
*/
export const strInsert = (str:string, size, insertStr:string): string => {
const {enterLen,maxLen} = size
const strLen:number = str.length
if(strLen<maxLen){
//缺省文字,前侧补齐空格\xa0 按照enterLen整数倍来补齐
const allSpaceLen = maxLen-strLen
const averageSpaceLen = Math.floor((maxLen-strLen)/2)
const beforSpaceLen = Math.floor(averageSpaceLen/enterLen)*enterLen||averageSpaceLen
const afterSpaceLen = allSpaceLen - beforSpaceLen
str = `${str}`
}else if(strLen>maxLen){
//多余文字拦截.
str=str.slice(0,maxLen-size.enterLen)+cutNodeLabelPoint
}
let newStr = '';
let countLen = 0;
for (let i = 0; i < str.length; i++) {
if(countLen === enterLen){
newStr += `${insertStr}${str[i]}`;
countLen = 1
}else{
newStr += str[i];
countLen++
}
}
return newStr;
};
export const cutNodeLabelPoint:string = '...'
自定义事件系统-SampleBehavior
简单看看某个事件里面做了什么
scss
// drag事件注册
const nodehDragStart = (evt: IG6GraphEvent) => {
recordStartPositon(evt)
};
graph.on('node:dragstart', nodehDragStart)
const nodehDragEnd = (evt: IG6GraphEvent) => {
computedEndPoisition(atlasData,graph,evt)
};
graph.on('node:dragend', nodehDragEnd)
拖拽父节点子节点跟随
当前拖拽只能拖拽选中的节点,但是想要节点跟随父节点整体做位置偏移需要计算并update所有子节点position,节点跟随的事件触发在上一part的 recordStartPositon, computedEndPoisition
ini
import type { IG6GraphEvent } from '@antv/graphin';
import type { INode, NodeConfig } from '@antv/g6';
import { decimalsSubtract, decimalsAdd } from '../../../../utils/utils';
/*
* 1. 获取起始位置
* 2. 获取结束位置
* 3. 2-1=3
* 3. 子节点全部按3比例跟随移动:updatePosition
*/
// 计算
let positions = [];
export const recordStartPositon = (evt: IG6GraphEvent) => {
positions = [{ x: evt.x, y: evt.y }];
};
export const computedEndPoisition = (atlasData, graph, evt: IG6GraphEvent) => {
positions.push({ x: evt.x, y: evt.y });
const computedMove = {
x: decimalsSubtract(positions?.[1]?.x ?? 0, positions?.[0]?.x ?? 0),
y: decimalsSubtract(positions?.[1]?.y ?? 0, positions?.[0]?.y ?? 0)
};
const node = evt.item as INode;
const model = node.getModel() as NodeConfig;
const { id } = model;
const dfsSecondChildrenAllMovePosition = (targetId, graph, atlasData) => {
if (!targetId) return;
const targetNodes: [] = atlasData.edges.filter(
edgeRelation => edgeRelation.source === targetId
);
if (!targetNodes.length) return;
targetNodes.forEach(item => {
const demoNode = graph.findById(item.target);
const Bbox = demoNode.getBBox();
const newPostion = {
x: decimalsAdd(Bbox.centerX, computedMove.x),
y: decimalsAdd(Bbox.centerY, computedMove.y)
};
graph.updateItem(demoNode, newPostion);
dfsSecondChildrenAllMovePosition(item.target, graph, atlasData);
});
};
dfsSecondChildrenAllMovePosition(id, graph, atlasData);
};
其他功能
ToolTip
建议自己写,这样可以自定义卡片内容
是否全屏
ini
/* eslint-disable no-undef */
type RFSMethodName = 'webkitRequestFullScreen' | 'requestFullscreen' | 'msRequestFullscreen' | 'mozRequestFullScreen';
type EFSMethodName = 'webkitExitFullscreen' | 'msExitFullscreen' | 'mozCancelFullScreen' | 'exitFullscreen';
type FSEPropName = 'webkitFullscreenElement' | 'msFullscreenElement' | 'mozFullScreenElement' | 'fullscreenElement';
type ONFSCPropName = 'onfullscreenchange' | 'onwebkitfullscreenchange' | 'onmozfullscreenchange' | 'MSFullscreenChange';
/**
* caniuse
* https://caniuse.com/#search=Fullscreen
* 参考 MDN, 并不确定是否有o前缀的, 暂时不加入
* https://developer.mozilla.org/zh-CN/docs/Web/API/Element/requestFullscreen
* 各个浏览器
* https://www.wikimoe.com/?post=82
*/
const DOC_EL = document.documentElement;
let headEl = DOC_EL.querySelector('head');
const styleEl = document.createElement('style');
let TYPE_REQUEST_FULL_SCREEN: RFSMethodName = 'requestFullscreen';
let TYPE_EXIT_FULL_SCREEN: EFSMethodName = 'exitFullscreen';
let TYPE_FULL_SCREEN_ELEMENT: FSEPropName = 'fullscreenElement';
let TYPE_ON_FULL_SCREEN_CHANGE: ONFSCPropName = 'onfullscreenchange';
if (`webkitRequestFullScreen` in DOC_EL) {
TYPE_REQUEST_FULL_SCREEN = 'webkitRequestFullScreen';
TYPE_EXIT_FULL_SCREEN = 'webkitExitFullscreen';
TYPE_FULL_SCREEN_ELEMENT = 'webkitFullscreenElement';
TYPE_ON_FULL_SCREEN_CHANGE = 'onwebkitfullscreenchange';
} else if (`msRequestFullscreen` in DOC_EL) {
TYPE_REQUEST_FULL_SCREEN = 'msRequestFullscreen';
TYPE_EXIT_FULL_SCREEN = 'msExitFullscreen';
TYPE_FULL_SCREEN_ELEMENT = 'msFullscreenElement';
TYPE_ON_FULL_SCREEN_CHANGE = 'MSFullscreenChange';
} else if (`mozRequestFullScreen` in DOC_EL) {
TYPE_REQUEST_FULL_SCREEN = 'mozRequestFullScreen';
TYPE_EXIT_FULL_SCREEN = 'mozCancelFullScreen';
TYPE_FULL_SCREEN_ELEMENT = 'mozFullScreenElement';
TYPE_ON_FULL_SCREEN_CHANGE = 'onmozfullscreenchange';
} else if (!(`requestFullscreen` in DOC_EL)) {
throw `当前浏览器不支持Fullscreen API !`;
}
/**
* 如果传入的不是HTMLElement,
* 比如是EventTarget
* 那么返回document.documentElement
* @param el 目标元素
* @returns 目标元素或者document.documentElement
*/
function getCurrentElement(el?: HTMLElement) {
return el instanceof HTMLElement ? el : DOC_EL;
}
/**
* 启用全屏
* @param 元素
* @param 选项
*/
export function beFull(el?: HTMLElement, backgroundColor?: string): Promise<void> {
if (backgroundColor) {
if (null === headEl) {
headEl = document.createElement('head');
}
styleEl.innerHTML = `:fullscreen{background-color:${backgroundColor};}`;
headEl.appendChild(styleEl);
}
return getCurrentElement(el)[TYPE_REQUEST_FULL_SCREEN]();
}
/**
* 退出全屏
*/
export function exitFull(): Promise<void> {
if (DOC_EL.contains(styleEl)) {
headEl?.removeChild(styleEl)
}
return document[TYPE_EXIT_FULL_SCREEN]();
}
/**
* 元素是否全屏
* @param 目标元素
*/
export function isFull(el?: HTMLElement): boolean {
return getCurrentElement(el) === document[TYPE_FULL_SCREEN_ELEMENT]
}
/**
* 切换全屏/关闭
* @param 目标元素
* @returns Promise
*/
export function toggleFull(el?: HTMLElement, backgroundColor?: string): boolean {
if (isFull(el)) {
exitFull();
return false;
} else {
beFull(el, backgroundColor)
return true;
}
}
推荐阅读
开源作品
- 政采云前端小报
开源地址 www.zoo.team/openweekly/ (小报官网首页有微信交流群)
- 商品选择 sku 插件
开源地址 github.com/zcy-inc/sku...
招贤纳士
政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。
如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊......如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com