动态渲染拓扑图方案探究

前言 动态渲染拓扑图方案探究
文章图片

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

  • ECharts
    • 关系图
  • AntV
    • G6
      • Graphin
源码解析 ECharts源码
动态渲染拓扑图方案探究
文章图片

整个ECharts核心对外输出是一个大的ECharts类,所有的类型都是基于其进行new出来的实例,而其核心是基于对ZRender这样一个Canvas的封装
ECharts 动态渲染拓扑图方案探究
文章图片

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(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 = https://www.it610.com/article/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); } }

ZRender 动态渲染拓扑图方案探究
文章图片

动态渲染拓扑图方案探究
文章图片

ZRender是典型的MVC架构,其中M为Storage,主要对数据进行CRUD管理;V为Painter,对Canvas或SVG的生命周期及视图进行管理;C为Handler,负责事件的交互处理,实现dom事件的模拟封装
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(eventName: string, eventHandler: EventCallback | EventCallback, context?: Ctx): this { this.handler.on(eventName, eventHandler, context); return this; }off(eventName?: string, eventHandler?: EventCallback | EventCallback) { this.handler.off(eventName, eventHandler); }trigger(eventName: string, event?: unknown) { this.handler.trigger(eventName, event); }clear() {}dispose() {} }

G6源码
动态渲染拓扑图方案探究
文章图片

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

和ECharts的核心思路是一致的,都是基于MVC的模型,但是G6针对图的特点对元素进行了细化,用御术的话说就是“G6是面粉,ECharts是面条”,果然同一个作者开发的思路都是极其的相似
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): void { if (typeof nodeFn === 'function') { this.set('nodeMapper', nodeFn); } }public edge(edgeFn: (config: EdgeConfig) => Partial): void { if (typeof edgeFn === 'function') { this.set('edgeMapper', edgeFn); } }public combo(comboFn: (config: ComboConfig) => Partial): 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 = https://www.it610.com/article/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'); } } }

Graphin 动态渲染拓扑图方案探究
文章图片

Graphin是基于G6封装的React组件,可以直接进行使用
import React, { ErrorInfo } from 'react'; import G6, { Graph as IGraph, GraphOptions, GraphData, TreeGraphData } from '@antv/g6'; class Graphin extends React.PureComponent { 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 = https://www.it610.com/article/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 = https://www.it610.com/article/{ 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 ( { this.graphDOM = node; }} style={{ background: this.theme?.background, ...style }} />{isReady && ( <> { /** modes 不存在的时候,才启动默认的behaviros,否则会覆盖用户自己传入的 */ !modes && ( {/* 拖拽画布 */} {/* 缩放画布 */} {/* 拖拽节点 */} {/* 点击节点 */} {/* 点击节点 */} {/* 圈选节点 */} ) }{/** resize 画布 */} {/**/} {this.props.children} )} ); } }

总结 【动态渲染拓扑图方案探究】数据可视化通常是基于Canvas进行渲染的,对于简单的图形渲染,我们常常一个实例一个实例去写,缺少系统性的统筹规划的概念,对于需要解决一类问题的可视化方案,可以借鉴ECharts及G6引擎的做法,基于MVC模型,将展示、行为及数据进行分离,对于特定方案细粒度的把控可以参考G6的方案。本质上,大数据可视化展示是一个兼具大数据、视觉传达、前端等多方交叉的领域,对于怎么进行数据粒度的优美展示,可以借鉴data-ink ratio以及利用力导布局的算法(ps:引入库伦斥力及胡克弹力阻尼衰减进行动效展示,同时配合边线权重进行节点聚合),对于这方面感兴趣的同学,可以参考今年SEE Conf的《图解万物——AntV图可视化分析解决方案》,数据可视化领域既专业又交叉,对于深挖此道的同学还是需要下一番功夫的。
参考
  • ECharts关系图官网
  • ECharts官方源码
  • ECharts 3.0源码简要分析1-总体架构
  • ZRender官方源码
  • ZRender源码分析1:总体结构
  • ZRender源码分析2:Storage(Model层)
  • ZRender源码分析3:Painter(View层)-上
  • ZRender源码分析4:Painter(View层)-中
  • ZRender源码分析5:Shape绘图详解
  • ZRender源码分析6:Shape对象详解之路径
  • G6官网
  • G6官方源码
  • G6源码阅读-part1-运行主流程
  • G6源码阅读-Part2-Item与Shape
  • G6源码阅读-Part3-绘制Paint
  • Graphin官方源码
  • Graphin官网

    推荐阅读