动态渲染拓扑图方案探究

前端 | 动态渲染拓扑图方案探究.png

前言

image

拓扑图是数据可视化领域一种比较常见的展示类型,目前业界常见的可视化展现的方案有ECharts、HighCharts、D3、AntV等。当前的项目使用的是基于ECharts的静态关系图渲染,为了后续可能扩展成动态的拓扑图渲染,本文探索了ECharts的原理以及G6的原理,也算是对自研一个可视化库的基本实现方法做了一个梳理。

方案选择

  • ECharts

  • 关系图

  • AntV

  • G6

  • Graphin

源码解析

ECharts源码

image

整个ECharts核心对外输出是一个大的ECharts类,所有的类型都是基于其进行new出来的实例,而其核心是基于对ZRender这样一个Canvas的封装

ECharts

image

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background-image: none; background-size: auto; background-attachment: scroll; background-origin: padding-box; background-clip: border-box; background-color: rgba(0, 0, 0, 0); background-position: 0% 0%; background-repeat: repeat repeat;">class ECharts extends Eventful { // 公共属性 group: string; // 私有属性 private _zr: zrender.ZRenderType; private _dom: HTMLElement; private _model: GlobalModel; private _throttledZrFlush: zrender.ZRenderType extends {flush: infer R} ? R : never; private _theme: ThemeOption; private _locale: LocaleOption; private _chartsViews: ChartView[] = []; private _chartsMap: {[viewId: string]: ChartView} = {}; private _componentsViews: ComponentView[] = []; private _componentsMap: {[viewId: string]: ComponentView} = {}; private _coordSysMgr: CoordinateSystemManager; private _api: ExtensionAPI; private _scheduler: Scheduler; private _messageCenter: MessageCenter; private _pendingActions: Payload[] = []; private _disposed: boolean; private _loadingFX: LoadingEffect; private _labelManager: LabelManager; private [OPTION_UPDATED_KEY]: boolean | {silent: boolean}; private [IN_MAIN_PROCESS_KEY]: boolean; private [CONNECT_STATUS_KEY]: ConnectStatus; private [STATUS_NEEDS_UPDATE_KEY]: boolean; // 保护属性 protected _eventProcessor: never; constructor( dom: HTMLElement, theme?: string | ThemeOption, opts?: { locale?: string | LocaleOption, renderer?: RendererType, devicePixelRatio?: number, useDirtyRect?: boolean, width?: number, height?: number } ) { super(new ECEventProcessor()); opts = opts || {}; if (typeof theme === 'string') { theme = themeStorage[theme] as object; } this._dom = dom; let defaultRenderer = 'canvas'; const zr = this._zr = zrender.init(dom, { renderer: opts.renderer || defaultRenderer, devicePixelRatio: opts.devicePixelRatio, width: opts.width, height: opts.height, useDirtyRect: opts.useDirtyRect == null ? defaultUseDirtyRect : opts.useDirtyRect }); this._locale = createLocaleObject(opts.locale || SYSTEM_LANG); this._coordSysMgr = new CoordinateSystemManager(); const api = this._api = createExtensionAPI(this); this._scheduler = new Scheduler(this, api, dataProcessorFuncs, visualFuncs); this._initEvents(); zr.animation.on('frame', this._onframe, this); bindRenderedEvent(zr, this); bindMouseEvent(zr, this); } private _onframe(): void {} getDom(): HTMLElement { return this._dom; } getId(): string { return this.id; } getZr(): zrender.ZRenderType { return this._zr; } setOption<Opt extends ECBasicOption>(option: Opt, notMerge?: boolean | SetOptionOpts, lazyUpdate?: boolean): void { if (lazyUpdate) { this[OPTION_UPDATED_KEY] = {silent: silent}; this[IN_MAIN_PROCESS_KEY] = false; this.getZr().wakeUp(); } else { prepare(this); updateMethods.update.call(this); this._zr.flush(); this[OPTION_UPDATED_KEY] = false; this[IN_MAIN_PROCESS_KEY] = false; flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); } } private getModel(): GlobalModel { return this._model; } getRenderedCanvas(opts?: { backgroundColor?: ZRColor pixelRatio?: number }): HTMLCanvasElement { if (!env.canvasSupported) { return; } opts = zrUtil.extend({}, opts || {}); opts.pixelRatio = opts.pixelRatio || this.getDevicePixelRatio(); opts.backgroundColor = opts.backgroundColor || this._model.get('backgroundColor'); const zr = this._zr; return (zr.painter as CanvasPainter).getRenderedCanvas(opts); } private _initEvents(): void { each(MOUSE_EVENT_NAMES, (eveName) => { const handler = (e: ElementEvent) => { const ecModel = this.getModel(); const el = e.target; let params: ECEvent; const isGlobalOut = eveName === 'globalout'; if (isGlobalOut) { params = {} as ECEvent; } else { el && findEventDispatcher(el, (parent) => { const ecData = getECData(parent); if (ecData && ecData.dataIndex != null) { const dataModel = ecData.dataModel || ecModel.getSeriesByIndex(ecData.seriesIndex); params = ( dataModel && dataModel.getDataParams(ecData.dataIndex, ecData.dataType) || {} ) as ECEvent; return true; } // If element has custom eventData of components else if (ecData.eventData) { params = zrUtil.extend({}, ecData.eventData) as ECEvent; return true; } }, true); } if (params) { let componentType = params.componentType; let componentIndex = params.componentIndex; if (componentType === 'markLine' || componentType === 'markPoint' || componentType === 'markArea' ) { componentType = 'series'; componentIndex = params.seriesIndex; } const model = componentType && componentIndex != null && ecModel.getComponent(componentType, componentIndex); const view = model && this[ model.mainType === 'series' ? '_chartsMap' : '_componentsMap' ][model.__viewId]; params.event = e; params.type = eveName; (this._eventProcessor as ECEventProcessor).eventInfo = { targetEl: el, packedEvent: params, model: model, view: view }; this.trigger(eveName, params); } }; (handler as any).zrEventfulCallAtLast = true; this._zr.on(eveName, handler, this); }); each(eventActionMap, (actionType, eventType) => { this._messageCenter.on(eventType, function (event) { this.trigger(eventType, event); }, this); }); // Extra events // TODO register? each( ['selectchanged'], (eventType) => { this._messageCenter.on(eventType, function (event) { this.trigger(eventType, event); }, this); } ); handleLegacySelectEvents(this._messageCenter, this, this._api); } dispatchAction( payload: Payload, opt?: boolean | { silent?: boolean, flush?: boolean | undefined } ): void { const silent = opt.silent; doDispatchAction.call(this, payload, silent); const flush = opt.flush; if (flush) { this._zr.flush(); } else if (flush !== false && env.browser.weChat) { this._throttledZrFlush(); } flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); } }</pre>

ZRender

image
image

ZRender是典型的MVC架构,其中M为Storage,主要对数据进行CRUD管理;V为Painter,对Canvas或SVG的生命周期及视图进行管理;C为Handler,负责事件的交互处理,实现dom事件的模拟封装

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background-image: none; background-size: auto; background-attachment: scroll; background-origin: padding-box; background-clip: border-box; background-color: rgba(0, 0, 0, 0); background-position: 0% 0%; background-repeat: repeat repeat;">class ZRender { // 公共属性 dom: HTMLElement id: number storage: Storage painter: PainterBase handler: Handler animation: Animation // 私有属性 private _sleepAfterStill = 10; private _stillFrameAccum = 0; private _needsRefresh = true private _needsRefreshHover = true private _darkMode = false; private _backgroundColor: string | GradientObject | PatternObject; constructor(id: number, dom: HTMLElement, opts?: ZRenderInitOpt) { opts = opts || {}; /** * @type {HTMLDomElement} / this.dom = dom; this.id = id; const storage = new Storage(); let rendererType = opts.renderer || 'canvas'; // TODO WebGL if (useVML) { throw new Error('IE8 support has been dropped since 5.0'); } if (!painterCtors[rendererType]) { // Use the first registered renderer. rendererType = zrUtil.keys(painterCtors)[0]; } if (!painterCtors[rendererType]) { throw new Error(Renderer '${rendererType}' is not imported. Please import it first.); } opts.useDirtyRect = opts.useDirtyRect == null ? false : opts.useDirtyRect; const painter = new painterCtors[rendererType](dom, storage, opts, id); this.storage = storage; this.painter = painter; const handerProxy = (!env.node && !env.worker) ? new HandlerProxy(painter.getViewportRoot(), painter.root) : null; this.handler = new Handler(storage, painter, handerProxy, painter.root); this.animation = new Animation({ stage: { update: () => this._flush(true) } }); this.animation.start(); } /* * 添加元素 / add(el: Element) { } /* * 删除元素 */ remove(el: Element) { } refresh() { this._needsRefresh = true; // Active the animation again. this.animation.start(); } private _flush(fromInside?: boolean) { let triggerRendered; const start = new Date().getTime(); if (this._needsRefresh) { triggerRendered = true; this.refreshImmediately(fromInside); } if (this._needsRefreshHover) { triggerRendered = true; this.refreshHoverImmediately(); } const end = new Date().getTime(); if (triggerRendered) { this._stillFrameAccum = 0; this.trigger('rendered', { elapsedTime: end - start }); } else if (this._sleepAfterStill > 0) { this._stillFrameAccum++; // Stop the animiation after still for 10 frames. if (this._stillFrameAccum > this._sleepAfterStill) { this.animation.stop(); } } } on<Ctx>(eventName: string, eventHandler: EventCallback<Ctx, unknown> | EventCallback<Ctx, unknown, ElementEvent>, context?: Ctx): this { this.handler.on(eventName, eventHandler, context); return this; } off(eventName?: string, eventHandler?: EventCallback<unknown, unknown> | EventCallback<unknown, unknown, ElementEvent>) { this.handler.off(eventName, eventHandler); } trigger(eventName: string, event?: unknown) { this.handler.trigger(eventName, event); } clear() { } dispose() { } }</pre>

G6源码

image

G6是AntV专门针对图开源的一个库,其底层通过对边和点的定义,以及对位置的确定,来进行图的绘制,其主要包括五大内容:1、图的元素:点、边、分组等;2、图的算法:DFS、BFS、图检测、最短路径、中心度等;3、图布局:force、circle、grid等;4、图渲染:Canvas及SVG等;5、图交互:框选、点选、拖拽等;而Graphin是基于G6的使用React封装的落地方案

G6

image

和ECharts的核心思路是一致的,都是基于MVC的模型,但是G6针对图的特点对元素进行了细化,用御术的话说就是“G6是面粉,ECharts是面条”,果然同一个作者开发的思路都是极其的相似

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background-image: none; background-size: auto; background-attachment: scroll; background-origin: padding-box; background-clip: border-box; background-color: rgba(0, 0, 0, 0); background-position: 0% 0%; background-repeat: repeat repeat;">export default abstract class AbstractGraph extends EventEmitter implements IAbstractGraph { protected animating: boolean; protected cfg: GraphOptions & { [key: string]: any }; protected undoStack: Stack; protected redoStack: Stack; public destroyed: boolean; constructor(cfg: GraphOptions) { super(); this.cfg = deepMix(this.getDefaultCfg(), cfg); this.init(); this.animating = false; this.destroyed = false; if (this.cfg.enabledStack) { this.undoStack = new Stack(this.cfg.maxStep); this.redoStack = new Stack(this.cfg.maxStep); } } protected init() { this.initCanvas(); const viewController = new ViewController(this); const modeController = new ModeController(this); const itemController = new ItemController(this); const stateController = new StateController(this); this.set({ viewController, modeController, itemController, stateController, }); this.initLayoutController(); this.initEventController(); this.initGroups(); this.initPlugins(); } protected abstract initLayoutController(): void; protected abstract initEventController(): void; protected abstract initCanvas(): void; protected abstract initPlugins(): void; protected initGroups(): void { const canvas: ICanvas = this.get('canvas'); const el: HTMLElement = this.get('canvas').get('el'); const { id } = el; const group: IGroup = canvas.addGroup({ id: ${id}-root, className: Global.rootContainerClassName, }); if (this.get('groupByTypes')) { const edgeGroup: IGroup = group.addGroup({ id: ${id}-edge, className: Global.edgeContainerClassName, }); const nodeGroup: IGroup = group.addGroup({ id: ${id}-node, className: Global.nodeContainerClassName, }); const comboGroup: IGroup = group.addGroup({ id: ${id}-combo, className: Global.comboContainerClassName, }); // 用于存储自定义的群组 comboGroup.toBack(); this.set({ nodeGroup, edgeGroup, comboGroup }); } const delegateGroup: IGroup = group.addGroup({ id: ${id}-delegate, className: Global.delegateContainerClassName, }); this.set({ delegateGroup }); this.set('group', group); } public node(nodeFn: (config: NodeConfig) => Partial<NodeConfig>): void { if (typeof nodeFn === 'function') { this.set('nodeMapper', nodeFn); } } public edge(edgeFn: (config: EdgeConfig) => Partial<EdgeConfig>): void { if (typeof edgeFn === 'function') { this.set('edgeMapper', edgeFn); } } public combo(comboFn: (config: ComboConfig) => Partial<ComboConfig>): void { if (typeof comboFn === 'function') { this.set('comboMapper', comboFn); } } public addBehaviors( behaviors: string | ModeOption | ModeType[], modes: string | string[], ): AbstractGraph { const modeController: ModeController = this.get('modeController'); modeController.manipulateBehaviors(behaviors, modes, true); return this; } public removeBehaviors( behaviors: string | ModeOption | ModeType[], modes: string | string[], ): AbstractGraph { const modeController: ModeController = this.get('modeController'); modeController.manipulateBehaviors(behaviors, modes, false); return this; } public paint(): void { this.emit('beforepaint'); this.get('canvas').draw(); this.emit('afterpaint'); } public render(): void { const self = this; this.set('comboSorted', false); const data: GraphData = this.get('data'); if (this.get('enabledStack')) { // render 之前清空 redo 和 undo 栈 this.clearStack(); } if (!data) { throw new Error('data must be defined first'); } const { nodes = [], edges = [], combos = [] } = data; this.clear(); this.emit('beforerender'); each(nodes, (node: NodeConfig) => { self.add('node', node, false, false); }); // process the data to tree structure if (combos && combos.length !== 0) { const comboTrees = plainCombosToTrees(combos, nodes); this.set('comboTrees', comboTrees); // add combos self.addCombos(combos); } each(edges, (edge: EdgeConfig) => { self.add('edge', edge, false, false); }); const animate = self.get('animate'); if (self.get('fitView') || self.get('fitCenter')) { self.set('animate', false); } // layout const layoutController = self.get('layoutController'); if (layoutController) { layoutController.layout(success); if (this.destroyed) return; } else { if (self.get('fitView')) { self.fitView(); } if (self.get('fitCenter')) { self.fitCenter(); } self.emit('afterrender'); self.set('animate', animate); } // 将在 onLayoutEnd 中被调用 function success() { // fitView 与 fitCenter 共存时,fitView 优先,fitCenter 不再执行 if (self.get('fitView')) { self.fitView(); } else if (self.get('fitCenter')) { self.fitCenter(); } self.autoPaint(); self.emit('afterrender'); if (self.get('fitView') || self.get('fitCenter')) { self.set('animate', animate); } } if (!this.get('groupByTypes')) { if (combos && combos.length !== 0) { this.sortCombos(); } else { // 为提升性能,选择数量少的进行操作 if (data.nodes && data.edges && data.nodes.length < data.edges.length) { const nodesArr = this.getNodes(); // 遍历节点实例,将所有节点提前。 nodesArr.forEach((node) => { node.toFront(); }); } else { const edgesArr = this.getEdges(); // 遍历节点实例,将所有节点提前。 edgesArr.forEach((edge) => { edge.toBack(); }); } } } if (this.get('enabledStack')) { this.pushStack('render'); } } }</pre>

Graphin

image

Graphin是基于G6封装的React组件,可以直接进行使用

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background-image: none; background-size: auto; background-attachment: scroll; background-origin: padding-box; background-clip: border-box; background-color: rgba(0, 0, 0, 0); background-position: 0% 0%; background-repeat: repeat repeat;">import React, { ErrorInfo } from 'react'; import G6, { Graph as IGraph, GraphOptions, GraphData, TreeGraphData } from '@antv/g6'; class Graphin extends React.PureComponent<GraphinProps, GraphinState> { static registerNode: RegisterFunction = (nodeName, options, extendedNodeName) => { G6.registerNode(nodeName, options, extendedNodeName); }; static registerEdge: RegisterFunction = (edgeName, options, extendedEdgeName) => { G6.registerEdge(edgeName, options, extendedEdgeName); }; static registerCombo: RegisterFunction = (comboName, options, extendedComboName) => { G6.registerCombo(comboName, options, extendedComboName); }; static registerBehavior(behaviorName: string, behavior: any) { G6.registerBehavior(behaviorName, behavior); } static registerFontFamily(iconLoader: IconLoader): { [icon: string]: any } { /** 注册 font icon / const iconFont = iconLoader(); const { glyphs, fontFamily } = iconFont; const icons = glyphs.map((item) => { return { name: item.name, unicode: String.fromCodePoint(item.unicode_decimal), }; }); return new Proxy(icons, { get: (target, propKey: string) => { const matchIcon = target.find((icon) => { return icon.name === propKey; }); if (!matchIcon) { console.error(%c fontFamily:${fontFamily},does not found ${propKey} icon); return ''; } return matchIcon?.unicode; }, }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any static registerLayout(layoutName: string, layout: any) { G6.registerLayout(layoutName, layout); } graphDOM: HTMLDivElement | null = null; graph: IGraph; layout: LayoutController; width: number; height: number; isTree: boolean; data: GraphinTreeData | GraphinData | undefined; options: GraphOptions; apis: ApisType; theme: ThemeData; constructor(props: GraphinProps) { super(props); const { data, layout, width, height, ...otherOptions } = props; this.data = data; this.isTree = Boolean(props.data && props.data.children) || TREE_LAYOUTS.indexOf(String(layout && layout.type)) !== -1; this.graph = {} as IGraph; this.height = Number(height); this.width = Number(width); this.theme = {} as ThemeData; this.apis = {} as ApisType; this.state = { isReady: false, context: { graph: this.graph, apis: this.apis, theme: this.theme, }, }; this.options = { ...otherOptions } as GraphOptions; this.layout = {} as LayoutController; } initData = (data: GraphinProps['data']) => { if (data.children) { this.isTree = true; } console.time('clone data'); this.data = cloneDeep(data); console.timeEnd('clone data'); }; initGraphInstance = () => { const { theme, data, layout, width, height, defaultCombo, defaultEdge, defaultNode, nodeStateStyles, edgeStateStyles, comboStateStyles, modes = { default: [] }, animate, ...otherOptions } = this.props; const { clientWidth, clientHeight } = this.graphDOM as HTMLDivElement; this.initData(data); this.width = Number(width) || clientWidth || 500; this.height = Number(height) || clientHeight || 500; const themeResult = getDefaultStyleByTheme(theme); const { defaultNodeStyle, defaultEdgeStyle, defaultComboStyle, defaultNodeStatusStyle, defaultEdgeStatusStyle, defaultComboStatusStyle, } = themeResult; this.theme = themeResult as ThemeData; this.isTree = Boolean(data.children) || TREE_LAYOUTS.indexOf(String(layout && layout.type)) !== -1; const isGraphinNodeType = defaultNode?.type === undefined || defaultNode?.type === defaultNodeStyle.type; const isGraphinEdgeType = defaultEdge?.type === undefined || defaultEdge?.type === defaultEdgeStyle.type; this.options = { container: this.graphDOM, renderer: 'canvas', width: this.width, height: this.height, animate: animate !== false, /* 默认样式 / defaultNode: isGraphinNodeType ? deepMix({}, defaultNodeStyle, defaultNode) : defaultNode, defaultEdge: isGraphinEdgeType ? deepMix({}, defaultEdgeStyle, defaultEdge) : defaultEdge, defaultCombo: deepMix({}, defaultComboStyle, defaultCombo), /* status 样式 / nodeStateStyles: deepMix({}, defaultNodeStatusStyle, nodeStateStyles), edgeStateStyles: deepMix({}, defaultEdgeStatusStyle, edgeStateStyles), comboStateStyles: deepMix({}, defaultComboStatusStyle, comboStateStyles), modes, ...otherOptions, } as GraphOptions; if (this.isTree) { this.options.layout = { ...layout }; this.graph = new G6.TreeGraph(this.options); } else { this.graph = new G6.Graph(this.options); } this.graph.data(this.data as GraphData | TreeGraphData); /* 初始化布局 / if (!this.isTree) { this.layout = new LayoutController(this); this.layout.start(); } this.graph.get('canvas').set('localRefresh', false); this.graph.render(); this.initStatus(); this.apis = ApiController(this.graph); }; updateLayout = () => { this.layout.changeLayout(); }; componentDidMount() { console.log('did mount...'); this.initGraphInstance(); this.setState({ isReady: true, context: { graph: this.graph, apis: this.apis, theme: this.theme, }, }); } updateOptions = () => { const { layout, data, ...options } = this.props; return options; }; initStatus = () => { if (!this.isTree) { const { data } = this.props; const { nodes = [], edges = [] } = data as GraphinData; nodes.forEach((node) => { const { status } = node; if (status) { Object.keys(status).forEach((k) => { this.graph.setItemState(node.id, k, Boolean(status[k])); }); } }); edges.forEach((edge) => { const { status } = edge; if (status) { Object.keys(status).forEach((k) => { this.graph.setItemState(edge.id, k, Boolean(status[k])); }); } }); } }; componentDidUpdate(prevProps: GraphinProps) { console.time('did-update'); const isDataChange = this.shouldUpdate(prevProps, 'data'); const isLayoutChange = this.shouldUpdate(prevProps, 'layout'); const isOptionsChange = this.shouldUpdate(prevProps, 'options'); const isThemeChange = this.shouldUpdate(prevProps, 'theme'); console.timeEnd('did-update'); const { data } = this.props; const isGraphTypeChange = prevProps.data.children !== data.children; /* 图类型变化 / if (isGraphTypeChange) { this.initGraphInstance(); console.log('%c isGraphTypeChange', 'color:grey'); } /* 配置变化 / if (isOptionsChange) { this.updateOptions(); console.log('isOptionsChange'); } /* 数据变化 / if (isDataChange) { this.initData(data); this.layout.changeLayout(); this.graph.data(this.data as GraphData | TreeGraphData); this.graph.changeData(this.data as GraphData | TreeGraphData); this.initStatus(); this.apis = ApiController(this.graph); console.log('%c isDataChange', 'color:grey'); this.setState((preState) => { return { ...preState, context: { graph: this.graph, apis: this.apis, theme: this.theme, }, }; }); return; } /* 布局变化 / if (isLayoutChange) { /* * TODO * 1. preset 前置布局判断问题 * 2. enablework 问题 * 3. G6 LayoutController 里的逻辑 / this.layout.changeLayout(); this.layout.refreshPosition(); /* 走G6的layoutController / // this.graph.updateLayout(); console.log('%c isLayoutChange', 'color:grey'); } } /* * 组件移除的时候 / componentWillUnmount() { this.clear(); } /* * 组件崩溃的时候 * @param error * @param info / componentDidCatch(error: Error, info: ErrorInfo) { console.error('Catch component error: ', error, info); } clear = () => { if (this.layout && this.layout.destroyed) { this.layout.destroy(); // tree graph } this.layout = {} as LayoutController; this.graph!.clear(); this.data = { nodes: [], edges: [], combos: [] }; this.graph!.destroy(); }; shouldUpdate(prevProps: GraphinProps, key: string) { / eslint-disable react/destructuring-assignment / const prevVal = prevProps[key]; const currentVal = this.props[key] as DiffValue; const isEqual = deepEqual(prevVal, currentVal); return !isEqual; } render() { const { isReady } = this.state; const { modes, style } = this.props; return ( <GraphinContext.Provider value={this.state.context}> <div id="graphin-container"> <div data-testid="custom-element" className="graphin-core" ref={(node) => { this.graphDOM = node; }} style={{ background: this.theme?.background, ...style }} /> <div className="graphin-components"> {isReady && ( <> { /* modes 不存在的时候,才启动默认的behaviros,否则会覆盖用户自己传入的 / !modes && ( <React.Fragment> {/ 拖拽画布 /} <DragCanvas /> {/ 缩放画布 /} <ZoomCanvas /> {/ 拖拽节点 /} <DragNode /> {/ 点击节点 /} <DragCombo /> {/ 点击节点 /} <ClickSelect /> {/ 圈选节点 /} <BrushSelect /> </React.Fragment> ) } {/* resize 画布 /} <ResizeCanvas graphDOM={this.graphDOM as HTMLDivElement} /> <Hoverable bindType="node" /> {/ <Hoverable bindType="edge" /> */} {this.props.children} </> )} </div> </div> </GraphinContext.Provider> ); } }</pre>

总结

数据可视化通常是基于Canvas进行渲染的,对于简单的图形渲染,我们常常一个实例一个实例去写,缺少系统性的统筹规划的概念,对于需要解决一类问题的可视化方案,可以借鉴ECharts及G6引擎的做法,基于MVC模型,将展示、行为及数据进行分离,对于特定方案细粒度的把控可以参考G6的方案。本质上,大数据可视化展示是一个兼具大数据、视觉传达、前端等多方交叉的领域,对于怎么进行数据粒度的优美展示,可以借鉴data-ink ratio以及利用力导布局的算法(ps:引入库伦斥力及胡克弹力阻尼衰减进行动效展示,同时配合边线权重进行节点聚合),对于这方面感兴趣的同学,可以参考今年SEE Conf的《图解万物——AntV图可视化分析解决方案》,数据可视化领域既专业又交叉,对于深挖此道的同学还是需要下一番功夫的。

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容