|
图可视化探索与实践
363 背景科普随着公司业务扩大,数据日益复杂,当下非常需要一种对用户理解更简便、交互更友好的数据关系的可视化产品,围绕这个场景,本文带你深入浅出前端如何开发图可视化(不含树图)。什么是图模型图模型是一种用于表示对象之间关系的抽象数据结构。它由节点(Nodes)和边(Edges)组成,节点代表对象,边表示节点之间的连接或关系。图模型可用于建模和分析各种复杂的关系型数据,如社交网络、知识图谱、地理数据等。图模型具有以下特点:节点:节点表示图中的对象或实体,可以携带属性和元数据来描述其特征。边:边表示节点之间的关系,可以有方向性或无方向性。边也可以携带属性,用于描述关系的特性。图遍历:通过遍历节点和边,可以在图中进行查询、分析和操作。图常用的数据结构在 antv 的 G6 中,图数据结构可以通过 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实现DFSclass Graph { adjacencyList: Map; 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 GraphinG6G6 使用 WebGL 技术,在渲染大规模关系图时表现出色, 提供了灵活的插件机制,可以扩展和定制特定需求的功能。学习起来api比较多,要花时间理解概念和使用方式,且是初始化实例的方式。const graph = new G6.Graph({ container: 'mountNode', width: 1000, height: 600, layout: { type: 'random', width: 300, height: 300, },});GraphinGraphin 是基于 G6引擎 的图可视化工具(上层用react封装了一层),使得使用起来更加便捷。内置的可视化布局算法,更符合关系可视分析领域的解决方案。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 ( );};export default Demo;提供以下选型原则考虑:希望更自由地使用图形渲染引擎和自定义功能,可以选择 G6希望简化开发流程并且采用 React 框架,那么选择 Graphin 会更加方便。 因为有二开业务组件和多人协作诉求,最后选择了Graphin。基于Graphin二次开发以下是翻过源码和一些issue之后的二开功能补充。组件整体关系// 使用useContext向子组件共享api // 初始化逻辑 // 自定义tooltip // 自定义toolsBar // 事件系统
初始化-InitRender在初始化里面做了一些图谱展示的业务逻辑,因为业务是全量渲染所以使用dfs自行做了收起逻辑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文字过多换行处理动态计算文字超出隐藏/** * @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(strLenmaxLen){ //多余文字拦截. str=str.slice(0,maxLen-size.enterLen)+cutNodeLabelPoint } let newStr = ''; let countLen = 0; for (let i = 0; i { recordStartPositon(evt)};graph.on('node:dragstart', nodehDragStart)const nodehDragEnd = (evt: IG6GraphEvent) => { computedEndPoisition(atlasData,graph,evt)};graph.on('node:dragend', nodehDragEnd)拖拽父节点子节点跟随当前拖拽只能拖拽选中的节点,但是想要节点跟随父节点整体做位置偏移需要计算并update所有子节点position,节点跟随的事件触发在上一part的 recordStartPositon, computedEndPoisitionimport 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建议自己写,这样可以自定义卡片内容是否全屏/* 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): romise { 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(): romise { 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 romise*/export function toggleFull(el?: HTMLElement, backgroundColor?: string): boolean { if (isFull(el)) { exitFull(); return false; } else { beFull(el, backgroundColor) return true; }},Base 杭州,一个富有激情和技术匠心精神的成长型团队。前端团队,一个年轻富有激情和创造力的前端团队。团队现有 80 余个前端小伙伴,平均年龄 27 岁,近 4 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、智能化平台、性能体验、云端应用、数据分析、错误监控及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com
|
|