图可视化探索与实践

背景科普

随着公司业务扩大,数据日益复杂,当下非常需要一种对用户理解更简便、交互更友好的数据关系的可视化产品,围绕这个场景,本文带你深入浅出前端如何开发图可视化(不含树图)。

什么是图模型

图模型是一种用于表示对象之间关系的抽象数据结构。它由节点(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;
  }
}

推荐阅读

ES亿级商品索引拆分实战

在 ARM 环境下搭建原生 Hadoop 集群

利用流量保障搜索质量的实践

Rc-form: 消失的"Ta"

Redis Bigkey 排查

开源作品

  • 政采云前端小报

开源地址 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

相关推荐
Leo.yuan34 分钟前
数据量大Excel卡顿严重?选对报表工具提高10倍效率
数据库·数据分析·数据可视化·powerbi
B站计算机毕业设计超人3 小时前
计算机毕业设计Python+大模型农产品价格预测 ARIMA自回归模型 农产品可视化 农产品爬虫 机器学习 深度学习 大数据毕业设计 Django Flask
大数据·爬虫·python·深度学习·机器学习·课程设计·数据可视化
CodeCraft Studio9 小时前
「实战应用」如何可视化 DHTMLX Scheduler 中的资源工作量?
javascript·ui·数据可视化
B站计算机毕业设计超人2 天前
计算机毕业设计Python+Neo4j中华古诗词可视化 古诗词智能问答系统 古诗词数据分析 古诗词情感分析 PyTorch Tensorflow LSTM
pytorch·python·深度学习·机器学习·知识图谱·neo4j·数据可视化
B站计算机毕业设计超人2 天前
计算机毕业设计Python+大模型斗鱼直播可视化 直播预测 直播爬虫 直播数据分析 直播大数据 大数据毕业设计 机器学习 深度学习
爬虫·python·深度学习·机器学习·数据分析·课程设计·数据可视化
知行行行2 天前
手把手教学系列之R语言绘图——饼图
数据可视化
FreedomLeo13 天前
Python数据分析NumPy和pandas(二十九、其他Python可视化工具)
python·数据分析·数据可视化·numpy和pandas
B站计算机毕业设计超人3 天前
计算机毕业设计Python+图神经网络考研院校推荐系统 考研分数线预测 考研推荐系统 考研爬虫 考研大数据 Hadoop 大数据毕设 机器学习 深度学习
爬虫·python·深度学习·机器学习·知识图谱·数据可视化·推荐算法
希艾席蒂恩3 天前
选择适合你的报表工具,山海鲸报表与Tableau深度对比
信息可视化·数据挖掘·数据分析·数据可视化·报表工具·免费软件
qingyunliushuiyu3 天前
智能数据分析系统-助力企业迈向数字化转型时代
数据分析·数据可视化·数据分析系统·智能数据分析系统·bi数据分析系统