* feat: unified datachange from changeData, addData, updateData, and removeData; feat: item definition and first drawing

* feat: update canvas while addData; feat: update canvas while updateData (node); chore: unified additems, removeitems, updateitems to be itemchange hook

* feat: init layout controller

* feat: node and edge updating and drawing

* chore: neaten

* feat: draw and update labels for node and edge

* feat: icon for edge; feat: custom node and edge and register to lib

* feat: init layout controller

* fix: use latest g-webgl

* feat: layout controller should support  &  option

* fix: registry typo & add custom layout test case

* feat: state related API for graph and item

* chore: update notes

---------

Co-authored-by: Yanyan-Wang <yanyanwang93@gmail.com>
This commit is contained in:
xiaoiver 2023-03-01 14:45:57 +08:00 committed by GitHub
parent 875a9eb39b
commit 0f23a181db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1457 additions and 133 deletions

View File

@ -47,6 +47,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@antv/graphlib": "^2.0.0-alpha.0",
"eslint": "^7.11.0", "eslint": "^7.11.0",
"eslint-config-prettier": "^6.7.0", "eslint-config-prettier": "^6.7.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
@ -65,8 +66,7 @@
"tslint-config-airbnb": "^5.11.2", "tslint-config-airbnb": "^5.11.2",
"tslint-config-prettier": "^1.18.0", "tslint-config-prettier": "^1.18.0",
"tslint-eslint-rules": "^5.4.0", "tslint-eslint-rules": "^5.4.0",
"typescript": "^4.6.3", "typescript": "^4.6.3"
"@antv/graphlib": "^2.0.0-alpha.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
@ -88,4 +88,4 @@
"normalize-url": "^4.1.0", "normalize-url": "^4.1.0",
"sharp": "^0.30.4" "sharp": "^0.30.4"
} }
} }

View File

@ -61,7 +61,10 @@
"@antv/g": "^5.15.7", "@antv/g": "^5.15.7",
"@antv/g-canvas": "^1.9.28", "@antv/g-canvas": "^1.9.28",
"@antv/g-svg": "^1.8.36", "@antv/g-svg": "^1.8.36",
"@antv/graphlib": "^2.0.0-alpha.0", "@antv/g-webgl": "^1.7.44",
"@antv/graphlib": "^2.0.0",
"@antv/layout": "^1.0.0-alpha.17",
"@antv/layout-gpu": "^1.0.0-alpha.3",
"@antv/util": "~2.0.5", "@antv/util": "~2.0.5",
"typedoc-plugin-markdown": "^3.14.0" "typedoc-plugin-markdown": "^3.14.0"
}, },

View File

@ -1,6 +1,6 @@
import { Graph as GraphLib, ID } from '@antv/graphlib'; import { Graph as GraphLib, ID } from '@antv/graphlib';
import { GraphData, IGraph, ComboModel, ComboUserModel } from '../../types'; import { GraphData, IGraph, ComboModel, ComboUserModel } from '../../types';
import { registery } from '../../stdlib'; import { registry } from '../../stdlib';
import { getExtension } from '../../util/extension'; import { getExtension } from '../../util/extension';
import { clone, isArray, isNumber, isString, isFunction, isObject } from '@antv/util'; import { clone, isArray, isNumber, isString, isFunction, isObject } from '@antv/util';
import { NodeModel, NodeModelData, NodeUserModel, NodeUserModelData } from '../../types/node'; import { NodeModel, NodeModelData, NodeUserModel, NodeUserModelData } from '../../types/node';
@ -85,7 +85,7 @@ export class DataController {
return transform return transform
.map((config) => ({ .map((config) => ({
config, config,
func: getExtension(config, registery.useLib, 'transform'), func: getExtension(config, registry.useLib, 'transform'),
})) }))
.filter((ext) => !!ext.func); .filter((ext) => !!ext.func);
} }

View File

@ -1,7 +1,7 @@
import { IGraph } from "../../types"; import { isObject } from '@antv/util';
import { registery } from '../../stdlib'; import { registry } from '../../stdlib';
import { getExtension } from "../../util/extension"; import { IGraph } from '../../types';
import { isObject } from "@antv/util"; import { getExtension } from '../../util/extension';
/** /**
* Manages the interaction extensions and graph modes; * Manages the interaction extensions and graph modes;
@ -29,14 +29,16 @@ export class InteractionController {
/** /**
* Get the extensions from useLib, stdLib is a sub set of useLib. * Get the extensions from useLib, stdLib is a sub set of useLib.
* @returns * @returns
*/ */
private getExtensions() { private getExtensions() {
const { modes = {} } = this.graph.getSpecification(); const { modes = {} } = this.graph.getSpecification();
const modeBehaviors = {}; const modeBehaviors = {};
Object.keys(modes).forEach(mode => { Object.keys(modes).forEach((mode) => {
modeBehaviors[mode] = modes[mode].map(config => getExtension(config, registery.useLib, 'behavior')).filter(behavior => !!behavior); modeBehaviors[mode] = modes[mode]
}) .map((config) => getExtension(config, registry.useLib, 'behavior'))
.filter((behavior) => !!behavior);
});
return modeBehaviors; return modeBehaviors;
} }
@ -50,25 +52,35 @@ export class InteractionController {
// ... // ...
} }
/** /**
* Listener of graph's behaviorchange hook. Update, add, or remove behaviors from modes. * Listener of graph's behaviorchange hook. Update, add, or remove behaviors from modes.
* @param param contains action, modes, and behaviors * @param param contains action, modes, and behaviors
*/ */
private onBehaviorChange(self, param: { action: 'update' | 'add' | 'remove', modes: string[], behaviors: (string | { key: string, type: string })[] }) { private onBehaviorChange(
self,
param: {
action: 'update' | 'add' | 'remove';
modes: string[];
behaviors: (string | { key: string; type: string })[];
},
) {
const { action, modes, behaviors } = param; const { action, modes, behaviors } = param;
modes.forEach(mode => { modes.forEach((mode) => {
switch (action) { switch (action) {
case 'add': case 'add':
behaviors.forEach(config => self.extensions[mode].push(getExtension(config, registery.useLib, 'behavior'))); behaviors.forEach((config) =>
self.extensions[mode].push(getExtension(config, registry.useLib, 'behavior')),
);
break; break;
case 'remove': case 'remove':
behaviors.forEach(key => { behaviors.forEach((key) => {
self.extensions[mode] = self.extensions[mode].filter(behavior => behavior.getKey() === key) self.extensions[mode] = self.extensions[mode].filter(
(behavior) => behavior.getKey() === key,
);
}); });
break; break;
case 'update': case 'update':
behaviors.forEach(config => { behaviors.forEach((config) => {
if (isObject(config) && config.hasOwnProperty('key')) { if (isObject(config) && config.hasOwnProperty('key')) {
const behaviorItem = self.extensions[mode].find(behavior => behavior.getKey() === config.key); const behaviorItem = self.extensions[mode].find(behavior => behavior.getKey() === config.key);
if (behaviorItem) behaviorItem.updateConfig(config); if (behaviorItem) behaviorItem.updateConfig(config);
@ -80,4 +92,4 @@ export class InteractionController {
} }
}); });
} }
} }

View File

@ -1,6 +1,6 @@
import { GraphChange, ID } from '@antv/graphlib'; import { GraphChange, ID } from '@antv/graphlib';
import { ComboModel, IGraph } from '../../types'; import { ComboModel, IGraph } from '../../types';
import { registery } from '../../stdlib'; import { registry } from '../../stdlib';
import { getExtension } from '../../util/extension'; import { getExtension } from '../../util/extension';
import { GraphCore } from '../../types/data'; import { GraphCore } from '../../types/data';
import { NodeDisplayModel, NodeEncode, NodeModel, NodeModelData } from '../../types/node'; import { NodeDisplayModel, NodeEncode, NodeModel, NodeModelData } from '../../types/node';
@ -87,13 +87,13 @@ export class ItemController {
const comboTypes = ['circle-combo', 'rect-combo']; // TODO: WIP const comboTypes = ['circle-combo', 'rect-combo']; // TODO: WIP
return { return {
node: nodeTypes node: nodeTypes
.map((config) => getExtension(config, registery.useLib, 'node')) .map((config) => getExtension(config, registry.useLib, 'node'))
.filter(Boolean), .filter(Boolean),
edge: edgeTypes edge: edgeTypes
.map((config) => getExtension(config, registery.useLib, 'edge')) .map((config) => getExtension(config, registry.useLib, 'edge'))
.filter(Boolean), .filter(Boolean),
combo: comboTypes combo: comboTypes
.map((config) => getExtension(config, registery.useLib, 'combo')) .map((config) => getExtension(config, registry.useLib, 'combo'))
.filter(Boolean), .filter(Boolean),
}; };
} }

View File

@ -1,14 +1,129 @@
import { IGraph } from "../../types"; import { isLayoutWithIterations, Layout, LayoutMapping, Supervisor } from '@antv/layout';
import { stdLib } from '../../stdlib';
import { IGraph } from '../../types';
import { GraphCore } from '../../types/data';
import { LayoutOptions } from '../../types/layout';
/** /**
* Manages layout extensions and graph layout. * Manages layout extensions and graph layout.
* It will also emit `afterlayout` & `tick` events on Graph.
*/ */
export class LayoutController { export class LayoutController {
public extensions = {}; public extensions = {};
public graph: IGraph; public graph: IGraph;
private currentLayout: Layout<any>;
private currentSupervisor: Supervisor;
constructor(graph: IGraph<any>) { constructor(graph: IGraph<any>) {
this.graph = graph; this.graph = graph;
// this.tap(); this.tap();
} }
}
/**
* Subscribe the lifecycle of graph.
*/
private tap() {
this.graph.hooks.layout.tap(this.onLayout.bind(this));
}
private async onLayout(params: { graphCore: GraphCore; options?: LayoutOptions }) {
// Stop currentLayout if any.
this.stopLayout();
const { graphCore, options } = params;
const {
type,
workerEnabled,
animated,
iterations = 300,
...rest
} = {
...this.graph.getSpecification().layout,
...options,
};
// Find built-in layout algorithms.
const layoutCtor = stdLib.layouts[type];
if (!layoutCtor) {
throw new Error(`Unknown layout algorithm: ${type}`);
}
// Initialize layout.
const layout = new layoutCtor(rest);
this.currentLayout = layout;
let positions: LayoutMapping;
if (workerEnabled) {
/**
* Run algorithm in WebWorker, `animated` option will be ignored.
*/
const supervisor = new Supervisor(graphCore, layout, { iterations });
this.currentSupervisor = supervisor;
positions = await supervisor.execute();
} else {
if (isLayoutWithIterations(layout)) {
if (animated) {
positions = await layout.execute(graphCore, {
onTick: (positionsOnTick: LayoutMapping) => {
// Display the animated process of layout.
this.updateNodesPosition(positionsOnTick);
this.graph.emit('tick', positionsOnTick);
},
});
} else {
/**
* Manually step simulation in a sync way. `onTick` won't get triggered in this case,
* there will be no animation either.
*/
layout.execute(graphCore);
layout.stop();
positions = layout.tick(iterations);
}
/**
* `onTick` will get triggered in this case.
*/
} else {
positions = await layout.execute(graphCore);
}
}
// Update nodes' positions.
this.updateNodesPosition(positions);
}
stopLayout() {
if (this.currentLayout && isLayoutWithIterations(this.currentLayout)) {
this.currentLayout.stop();
this.currentLayout = null;
}
if (this.currentSupervisor) {
this.currentSupervisor.stop();
this.currentSupervisor = null;
}
}
destroy() {
this.stopLayout();
if (this.currentSupervisor) {
this.currentSupervisor.kill();
}
}
private updateNodesPosition(positions: LayoutMapping) {
positions.nodes.forEach((node) => {
this.graph.updateData('node', {
id: node.id,
data: {
x: node.data.x,
y: node.data.y,
},
});
});
}
}

View File

@ -18,7 +18,7 @@ import { GraphCore } from '../types/data';
import { EdgeModel, EdgeModelData } from '../types/edge'; import { EdgeModel, EdgeModelData } from '../types/edge';
import { Hooks } from '../types/hook'; import { Hooks } from '../types/hook';
import { ITEM_TYPE } from '../types/item'; import { ITEM_TYPE } from '../types/item';
import { LayoutCommonConfig } from '../types/layout'; import { LayoutOptions } from '../types/layout';
import { NodeModel, NodeModelData } from '../types/node'; import { NodeModel, NodeModelData } from '../types/node';
import { FitViewRules, GraphAlignment } from '../types/view'; import { FitViewRules, GraphAlignment } from '../types/view';
import { createCanvas } from '../util/canvas'; import { createCanvas } from '../util/canvas';
@ -111,6 +111,7 @@ export default class Graph<B extends BehaviorRegistry> extends EventEmitter impl
graphCore: GraphCore; graphCore: GraphCore;
}>({ name: 'itemchange' }), }>({ name: 'itemchange' }),
render: new Hook<{ graphCore: GraphCore }>({ name: 'render' }), render: new Hook<{ graphCore: GraphCore }>({ name: 'render' }),
layout: new Hook<{ graphCore: GraphCore }>({ name: 'layout' }),
modechange: new Hook<{ mode: string }>({ name: 'modechange' }), modechange: new Hook<{ mode: string }>({ name: 'modechange' }),
behaviorchange: new Hook<{ behaviorchange: new Hook<{
action: 'update' | 'add' | 'remove'; action: 'update' | 'add' | 'remove';
@ -143,20 +144,28 @@ export default class Graph<B extends BehaviorRegistry> extends EventEmitter impl
* @returns * @returns
* @group Data * @group Data
*/ */
public read(data: GraphData) { public async read(data: GraphData) {
this.hooks.datachange.emit({ data, type: 'replace' }); this.hooks.datachange.emit({ data, type: 'replace' });
const emitRender = () => { const emitRender = async () => {
this.hooks.render.emit({ this.hooks.render.emit({
graphCore: this.dataController.graphCore, graphCore: this.dataController.graphCore,
}); });
this.emit('afterrender'); this.emit('afterrender');
// TODO: make read async?
await this.hooks.layout.emitLinearAsync({
graphCore: this.dataController.graphCore,
});
this.emit('afterlayout');
}; };
if (this.canvasReady) { if (this.canvasReady) {
emitRender(); await emitRender();
} else { } else {
Promise.all( await Promise.all(
[this.backgroundCanvas, this.canvas, this.transientCanvas].map((canvas) => canvas.ready), [this.backgroundCanvas, this.canvas, this.transientCanvas].map((canvas) => canvas.ready),
).then(emitRender); );
await emitRender();
} }
} }
@ -167,12 +176,18 @@ export default class Graph<B extends BehaviorRegistry> extends EventEmitter impl
* @returns * @returns
* @group Data * @group Data
*/ */
public changeData(data: GraphData, type: 'replace' | 'mergeReplace' = 'mergeReplace') { public async changeData(data: GraphData, type: 'replace' | 'mergeReplace' = 'mergeReplace') {
this.hooks.datachange.emit({ data, type }); this.hooks.datachange.emit({ data, type });
this.hooks.render.emit({ this.hooks.render.emit({
graphCore: this.dataController.graphCore, graphCore: this.dataController.graphCore,
}); });
this.emit('afterrender'); this.emit('afterrender');
await this.hooks.layout.emitLinearAsync({
graphCore: this.dataController.graphCore,
});
this.emit('afterlayout');
} }
/** /**
@ -551,20 +566,20 @@ export default class Graph<B extends BehaviorRegistry> extends EventEmitter impl
// ===== layout ===== // ===== layout =====
/** /**
* Layout the graph (with current configurations if cfg is not assigned). * Layout the graph (with current configurations if cfg is not assigned).
* @param {LayoutCommonConfig} cfg layout configurations. if assigned, the layout spec of the graph will be updated in the same time
* @param {GraphAlignment} align align the result
* @param {Point} canvasPoint align the result
* @param {boolean} stack push it into stack
* @group Layout
*/ */
public layout( public async layout(options?: LayoutOptions) {
cfg?: LayoutCommonConfig, await this.hooks.layout.emitLinearAsync({
align?: GraphAlignment, graphCore: this.dataController.graphCore,
canvasPoint?: Point, options,
stack?: boolean, });
) { this.emit('afterlayout');
// TODO: LayoutConfig combination instead of LayoutCommonConfig }
// TODO
/**
* Some layout algorithms has many iterations which can be stopped at any time.
*/
public stopLayout() {
this.layoutController.stopLayout();
} }
/** /**

View File

@ -1,4 +1,4 @@
import { IHook } from "../types/hook"; import { IHook } from '../types/hook';
/** /**
* A hook class unified the definitions of tap, untap, and emit. * A hook class unified the definitions of tap, untap, and emit.
@ -16,14 +16,14 @@ export default class Hook<T> implements IHook<T> {
} }
/** /**
* Tap a listener to the corresponding lifecycle of this hook. * Tap a listener to the corresponding lifecycle of this hook.
* @param listener * @param listener
*/ */
public tap(listener: (param: T) => void) { public tap(listener: (param: T) => void) {
this.listeners.push(listener); this.listeners.push(listener);
} }
/** /**
* Remove a listener from the corresponding lifecycle of this hook. * Remove a listener from the corresponding lifecycle of this hook.
* @param listener * @param listener
*/ */
public unTap(listener: (param: T) => void) { public unTap(listener: (param: T) => void) {
const idx = this.listeners.indexOf(listener); const idx = this.listeners.indexOf(listener);
@ -31,28 +31,34 @@ export default class Hook<T> implements IHook<T> {
} }
/** /**
* Emit the corresponding lifecycle to call the listeners * Emit the corresponding lifecycle to call the listeners
* @param param * @param param
*/ */
public emit(param: T) { public emit(param: T) {
this.listeners.forEach(listener => listener(param)); this.listeners.forEach((listener) => listener(param));
} }
/** /**
* Linearly async emit the corresponding lifecycle to call the listeners * Linearly async emit the corresponding lifecycle to call the listeners
* @param param * @param param
*/ */
public async emitLinearAsync(param: T): Promise<void> { public async emitLinearAsync(param: T): Promise<void> {
return new Promise(async () => { for (const listener of this.listeners) {
let start = Promise.resolve(); await listener(param);
this.listeners.forEach(listener => { }
start = start.then(async () => new Promise(async (resolve, reject) => { // return new Promise(async () => {
try { // let start = Promise.resolve();
await listener(param); // this.listeners.forEach((listener) => {
resolve(); // start = start.then(
} catch (e) { // async () =>
reject(); // new Promise(async (resolve, reject) => {
} // try {
})); // await listener(param);
}); // resolve();
}); // } catch (e) {
// reject();
// }
// }),
// );
// });
// });
} }
} }

View File

@ -1,15 +1,16 @@
import { comboFromNode } from "./data/comboFromNode" import { registry as layoutRegistry } from '@antv/layout';
import DragCanvas from "./behavior/drag-canvas"; import { Lib } from '../types/stdlib';
import { Lib } from "../types/stdlib"; import DragCanvas from './behavior/drag-canvas';
import { CircleNode } from "./item/node"; import { comboFromNode } from './data/comboFromNode';
import { LineEdge } from "./item/edge"; import { LineEdge } from './item/edge';
import { CircleNode } from './item/node';
const stdLib = { const stdLib = {
transforms: { transforms: {
comboFromNode comboFromNode,
}, },
themes: {}, themes: {},
layouts: {}, // from @antv/layout layouts: layoutRegistry,
behaviors: { behaviors: {
'drag-canvas': DragCanvas 'drag-canvas': DragCanvas
}, },
@ -34,6 +35,6 @@ const useLib: Lib = {
combos: {}, combos: {},
}; };
const registery = { useLib }; const registry = { useLib };
export default registery; export default registry;
export { stdLib, registery }; export { stdLib, registry };

View File

@ -9,7 +9,7 @@ import { Padding, Point } from './common';
import { DataChangeType, GraphData } from './data'; import { DataChangeType, GraphData } from './data';
import { EdgeModel, EdgeUserModel } from './edge'; import { EdgeModel, EdgeUserModel } from './edge';
import { ITEM_TYPE } from './item'; import { ITEM_TYPE } from './item';
import { LayoutCommonConfig } from './layout'; import { LayoutOptions } from './layout';
import { NodeModel, NodeUserModel } from './node'; import { NodeModel, NodeUserModel } from './node';
import { Specification } from './spec'; import { Specification } from './spec';
import { FitViewRules, GraphAlignment } from './view'; import { FitViewRules, GraphAlignment } from './view';
@ -295,18 +295,9 @@ export interface IGraph<B extends BehaviorRegistry = BehaviorRegistry> extends E
// ===== layout ===== // ===== layout =====
/** /**
* Layout the graph (with current configurations if cfg is not assigned). * Layout the graph (with current configurations if cfg is not assigned).
* @param {LayoutCommonConfig} cfg layout configurations. if assigned, the layout spec of the graph will be updated in the same time
* @param {GraphAlignment} align align the result
* @param {Point} canvasPoint align the result
* @param {boolean} stack push it into stack
* @group Layout
*/ */
layout: ( layout: (options?: LayoutOptions) => Promise<void>;
cfg?: LayoutCommonConfig, stopLayout: () => void;
align?: GraphAlignment,
canvasPoint?: Point,
stack?: boolean,
) => void;
// ===== interaction ===== // ===== interaction =====
/** /**

View File

@ -1,8 +1,9 @@
import { DataChangeType, GraphCore, GraphData } from "./data"; import { DataChangeType, GraphCore, GraphData } from "./data";
import { NodeModel, NodeModelData, NodeUserModel } from "./node"; import { NodeModelData } from "./node";
import { EdgeModel, EdgeModelData, EdgeUserModel } from "./edge"; import { EdgeModelData } from "./edge";
import { ITEM_TYPE } from "./item"; import { ITEM_TYPE } from "./item";
import { GraphChange, ID } from "@antv/graphlib"; import { GraphChange, ID } from "@antv/graphlib";
import { LayoutOptions } from "./layout";
export interface IHook<T> { export interface IHook<T> {
name: string; name: string;
@ -10,10 +11,11 @@ export interface IHook<T> {
tap: (listener: (param: T) => void) => void; tap: (listener: (param: T) => void) => void;
unTap: (listener: (param: T) => void) => void; unTap: (listener: (param: T) => void) => void;
emit: (param: T) => void; emit: (param: T) => void;
emitLinearAsync: (param: T) => Promise<void>;
} }
export interface Hooks { export interface Hooks {
'init': IHook<void>; init: IHook<void>;
// data // data
'datachange': IHook<{ 'datachange': IHook<{
type: DataChangeType; type: DataChangeType;
@ -24,14 +26,14 @@ export interface Hooks {
changes: GraphChange<NodeModelData, EdgeModelData>[]; changes: GraphChange<NodeModelData, EdgeModelData>[];
graphCore: GraphCore; graphCore: GraphCore;
}>; }>;
'render': IHook<{ graphCore: GraphCore }>; // TODO: define param template render: IHook<{ graphCore: GraphCore }>; // TODO: define param template
// 'layout': IHook<any>; // TODO: define param template layout: IHook<{ graphCore: GraphCore; options?: LayoutOptions }>; // TODO: define param template
// 'updatelayout': IHook<any>; // TODO: define param template // 'updatelayout': IHook<any>; // TODO: define param template
'modechange': IHook<{ mode: string }>; modechange: IHook<{ mode: string }>;
'behaviorchange': IHook<{ behaviorchange: IHook<{
action: 'update' | 'add' | 'remove'; action: 'update' | 'add' | 'remove';
modes: string[]; modes: string[];
behaviors: (string | { type: string, key: string })[]; behaviors: (string | { type: string; key: string })[];
}>; }>;
'itemstatechange': IHook<{ 'itemstatechange': IHook<{
ids: ID[], ids: ID[],
@ -41,4 +43,4 @@ export interface Hooks {
// 'viewportchange': IHook<any>; // TODO: define param template // 'viewportchange': IHook<any>; // TODO: define param template
// 'destroy': IHook<any>; // TODO: define param template // 'destroy': IHook<any>; // TODO: define param template
// TODO: more timecycles here // TODO: more timecycles here
}; }

View File

@ -1,9 +1,69 @@
import {
CircularLayoutOptions,
ConcentricLayoutOptions,
D3ForceLayoutOptions,
ForceAtlas2LayoutOptions,
ForceLayoutOptions,
FruchtermanLayoutOptions,
GridLayoutOptions,
MDSLayoutOptions,
RadialLayoutOptions,
RandomLayoutOptions,
} from '@antv/layout';
export type LayoutOptions = (
export interface LayoutCommonConfig { | CircularLayout
type?: string; | RandomLayout
gpuEnabled?: boolean; | ConcentricLayout
| GridLayout
| MDSLayout
| RadialLayout
| FruchtermanLayout
| D3ForceLayout
| ForceLayout
| ForceAtlas2
) & {
workerEnabled?: boolean; workerEnabled?: boolean;
// Works when workerEnabled is true, config it with a visitable url to avoid visiting online version. animated?: boolean;
workerScriptURL?: string; iterations?: number;
} };
interface CircularLayout extends CircularLayoutOptions {
type: 'circular';
}
interface RandomLayout extends RandomLayoutOptions {
type: 'random';
}
interface GridLayout extends GridLayoutOptions {
type: 'grid';
}
interface MDSLayout extends MDSLayoutOptions {
type: 'mds';
}
interface ConcentricLayout extends ConcentricLayoutOptions {
type: 'concentric';
}
interface RadialLayout extends RadialLayoutOptions {
type: 'radial';
}
interface FruchtermanLayout extends FruchtermanLayoutOptions {
type: 'fruchterman' | 'fruchtermanGPU';
}
interface D3ForceLayout extends D3ForceLayoutOptions {
type: 'd3force';
}
interface ForceLayout extends ForceLayoutOptions {
type: 'force' | 'gforce';
}
interface ForceAtlas2 extends ForceAtlas2LayoutOptions {
type: 'forceAtlas2';
}

View File

@ -4,9 +4,9 @@ import { FetchDataConfig, GraphData, InlineDataConfig, TransformerFn } from "./d
import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeShapesEncode } from "./edge"; import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeShapesEncode } from "./edge";
import { NodeDisplayModel, NodeEncode, NodeModel, NodeShapesEncode } from "./node"; import { NodeDisplayModel, NodeEncode, NodeModel, NodeShapesEncode } from "./node";
import { GraphAlignment } from "./view"; import { GraphAlignment } from "./view";
import { LayoutCommonConfig } from "./layout";
import { ComboDisplayModel, ComboEncode, ComboModel, ComboShapesEncode } from "./combo"; import { ComboDisplayModel, ComboEncode, ComboModel, ComboShapesEncode } from "./combo";
import { BehaviorOptionsOf, BehaviorRegistry } from "./behavior"; import { BehaviorOptionsOf, BehaviorRegistry } from "./behavior";
import { LayoutOptions } from "./layout";
type rendererName = 'canvas' | 'svg' | 'webgl'; type rendererName = 'canvas' | 'svg' | 'webgl';
@ -15,24 +15,32 @@ export interface Specification<B extends BehaviorRegistry> {
container: string | HTMLElement; container: string | HTMLElement;
width?: number; width?: number;
height?: number; height?: number;
renderer?: rendererName | { renderer?:
type: rendererName, | rendererName
pixelRatio: number, | {
headless: boolean, type: rendererName;
}; pixelRatio: number;
headless: boolean;
};
zoom?: number; zoom?: number;
autoFit?: 'view' | 'center' | { autoFit?:
position: Point, | 'view'
alignment: GraphAlignment | 'center'
}; | {
position: Point;
alignment: GraphAlignment;
};
optimizeThreshold?: number; optimizeThreshold?: number;
/** data */ /** data */
data: GraphData | InlineDataConfig | FetchDataConfig; // TODO: more data: GraphData | InlineDataConfig | FetchDataConfig; // TODO: more
transform?: string[] | { transform?:
type: string, | string[]
[param: string]: unknown // TODO: generate by plugins | {
}[] | TransformerFn[]; type: string;
[param: string]: unknown; // TODO: generate by plugins
}[]
| TransformerFn[];
/** item */ /** item */
node?: ((data: NodeModel) => NodeDisplayModel) | NodeEncode; node?: ((data: NodeModel) => NodeDisplayModel) | NodeEncode;
@ -51,7 +59,7 @@ export interface Specification<B extends BehaviorRegistry> {
}; };
/** layout */ /** layout */
layout?: LayoutCommonConfig | LayoutCommonConfig[]; // TODO: Config comes from @antv/layout layout?: LayoutOptions | LayoutOptions[];
/** interaction */ /** interaction */
modes?: { modes?: {
@ -65,7 +73,7 @@ export interface Specification<B extends BehaviorRegistry> {
/** free plugins */ /** free plugins */
plugins?: { plugins?: {
name: string, name: string;
options: any; // TODO: configs from plugins options: any; // TODO: configs from plugins
}[] }[];
} }

View File

@ -1,7 +1,7 @@
import { Canvas } from '@antv/g'; import { Canvas } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Renderer as SVGRenderer } from '@antv/g-svg'; import { Renderer as SVGRenderer } from '@antv/g-svg';
// import { Renderer as WebGLRenderer } from '@antv/g-webgl'; import { Renderer as WebGLRenderer } from '@antv/g-webgl';
import { isString } from '@antv/util'; import { isString } from '@antv/util';
/** /**
@ -28,8 +28,7 @@ export const createCanvas = (
Renderer = SVGRenderer; Renderer = SVGRenderer;
break; break;
case 'webgl': case 'webgl':
// Renderer = WebGLRenderer; Renderer = WebGLRenderer;
// TODO
break; break;
default: default:
Renderer = CanvasRenderer; Renderer = CanvasRenderer;
@ -44,7 +43,7 @@ export const createCanvas = (
canvasTag.style.height = `${height}px`; canvasTag.style.height = `${height}px`;
canvasTag.style.position = 'fixed'; canvasTag.style.position = 'fixed';
const containerDOM = isString(container) ? document.getElementById('container') : container; const containerDOM = isString(container) ? document.getElementById('container') : container;
containerDOM.appendChild(canvasTag); containerDOM!.appendChild(canvasTag);
return new Canvas({ return new Canvas({
canvas: canvasTag, canvas: canvasTag,
devicePixelRatio: pixelRatio, devicePixelRatio: pixelRatio,
@ -58,4 +57,4 @@ export const createCanvas = (
devicePixelRatio: pixelRatio, devicePixelRatio: pixelRatio,
renderer: new Renderer() renderer: new Renderer()
}); });
} }

View File

@ -1,6 +1,6 @@
import { BehaviorRegistry } from '../types/behavior'; import { BehaviorRegistry } from '../types/behavior';
import Graph from '../runtime/graph'; import Graph from '../runtime/graph';
import registery from '../stdlib'; import registry from '../stdlib';
/** /**
* Extend graph class with custom libs (extendLibrary), and extendLibrary will be merged into useLib. * Extend graph class with custom libs (extendLibrary), and extendLibrary will be merged into useLib.
@ -20,9 +20,9 @@ export const extend = <B1 extends BehaviorRegistry, B2 extends BehaviorRegistry>
): typeof Graph<B1 & B2> => { ): typeof Graph<B1 & B2> => {
// merged the extendLibrary to useLib for global usage // merged the extendLibrary to useLib for global usage
Object.keys(extendLibrary).forEach((cat) => { Object.keys(extendLibrary).forEach((cat) => {
registery.useLib[cat] = Object.assign({}, registery.useLib[cat], extendLibrary[cat] || {}); registry.useLib[cat] = Object.assign({}, registry.useLib[cat], extendLibrary[cat] || {});
Object.keys(registery.useLib[cat]).forEach((type) => { Object.keys(registry.useLib[cat]).forEach((type) => {
const extension = registery.useLib[cat][type]; const extension = registry.useLib[cat][type];
extension.type = type; extension.type = type;
}); });
}); });

View File

@ -0,0 +1,841 @@
import { GraphData } from '../../src';
const data: GraphData = {
nodes: [
{
id: 'Argentina',
data: {
name: 'Argentina',
},
},
{
id: 'Australia',
data: {
name: 'Australia',
},
},
{
id: 'Belgium',
data: {
name: 'Belgium',
},
},
{
id: 'Brazil',
data: {
name: 'Brazil',
},
},
{
id: 'Colombia',
data: {
name: 'Colombia',
},
},
{
id: 'Costa Rica',
data: {
name: 'Costa Rica',
},
},
{
id: 'Croatia',
data: {
name: 'Croatia',
},
},
{
id: 'Denmark',
data: {
name: 'Denmark',
},
},
{
id: 'Egypt',
data: {
name: 'Egypt',
},
},
{
id: 'England',
data: {
name: 'England',
},
},
{
id: 'France',
data: {
name: 'France',
},
},
{
id: 'Germany',
data: {
name: 'Germany',
},
},
{
id: 'Iceland',
data: {
name: 'Iceland',
},
},
{
id: 'IR Iran',
data: {
name: 'IR Iran',
},
},
{
id: 'Japan',
data: {
name: 'Japan',
},
},
{
id: 'Korea Republic',
data: {
name: 'Korea Republic',
},
},
{
id: 'Mexico',
data: {
name: 'Mexico',
},
},
{
id: 'Morocco',
data: {
name: 'Morocco',
},
},
{
id: 'Nigeria',
data: {
name: 'Nigeria',
},
},
{
id: 'Panama',
data: {
name: 'Panama',
},
},
{
id: 'Peru',
data: {
name: 'Peru',
},
},
{
id: 'Poland',
data: {
name: 'Poland',
},
},
{
id: 'Portugal',
data: {
name: 'Portugal',
},
},
{
id: 'Russia',
data: {
name: 'Russia',
},
},
{
id: 'Saudi Arabia',
data: {
name: 'Saudi Arabia',
},
},
{
id: 'Senegal',
data: {
name: 'Senegal',
},
},
{
id: 'Serbia',
data: {
name: 'Serbia',
},
},
{
id: 'Spain',
data: {
name: 'Spain',
},
},
{
id: 'Sweden',
data: {
name: 'Sweden',
},
},
{
id: 'Switzerland',
data: {
name: 'Switzerland',
},
},
{
id: 'Tunisia',
data: {
name: 'Tunisia',
},
},
{
id: 'Uruguay',
data: {
name: 'Uruguay',
},
},
],
edges: [
{
id: '0',
target: 'Russia',
source: 'Saudi Arabia',
data: {
target_score: 5,
source_score: 0,
directed: true,
},
},
{
id: '1',
target: 'Uruguay',
source: 'Egypt',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '2',
target: 'Russia',
source: 'Egypt',
data: {
target_score: 3,
source_score: 1,
directed: true,
},
},
{
id: '3',
target: 'Uruguay',
source: 'Saudi Arabia',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '4',
target: 'Uruguay',
source: 'Russia',
data: {
target_score: 3,
source_score: 0,
directed: true,
},
},
{
id: '5',
target: 'Saudi Arabia',
source: 'Egypt',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '6',
target: 'IR Iran',
source: 'Morocco',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '7',
target: 'Portugal',
source: 'Spain',
data: {
target_score: 3,
source_score: 3,
directed: false,
},
},
{
id: '8',
target: 'Portugal',
source: 'Morocco',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '9',
target: 'Spain',
source: 'IR Iran',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '10',
target: 'IR Iran',
source: 'Portugal',
data: {
target_score: 1,
source_score: 1,
directed: false,
},
},
{
id: '11',
target: 'Spain',
source: 'Morocco',
data: {
target_score: 2,
source_score: 2,
directed: false,
},
},
{
id: '12',
target: 'France',
source: 'Australia',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '13',
target: 'Denmark',
source: 'Peru',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '14',
target: 'Denmark',
source: 'Australia',
data: {
target_score: 1,
source_score: 1,
directed: false,
},
},
{
id: '15',
target: 'France',
source: 'Peru',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '16',
target: 'Denmark',
source: 'France',
data: {
target_score: 0,
source_score: 0,
directed: false,
},
},
{
id: '17',
target: 'Peru',
source: 'Australia',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '18',
target: 'Argentina',
source: 'Iceland',
data: {
target_score: 1,
source_score: 1,
},
},
{
id: '19',
target: 'Croatia',
source: 'Nigeria',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '20',
target: 'Croatia',
source: 'Argentina',
data: {
target_score: 3,
source_score: 0,
directed: true,
},
},
{
id: '21',
target: 'Nigeria',
source: 'Iceland',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '22',
target: 'Argentina',
source: 'Nigeria',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '23',
target: 'Croatia',
source: 'Iceland',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '24',
target: 'Serbia',
source: 'Costa Rica',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '25',
target: 'Brazil',
source: 'Switzerland',
data: {
target_score: 1,
source_score: 1,
directed: false,
},
},
{
id: '26',
target: 'Brazil',
source: 'Costa Rica',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '27',
target: 'Switzerland',
source: 'Serbia',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '28',
target: 'Brazil',
source: 'Serbia',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '29',
target: 'Switzerland',
source: 'Costa Rica',
data: {
target_score: 2,
source_score: 2,
directed: false,
},
},
{
id: '30',
target: 'Mexico',
source: 'Germany',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '31',
target: 'Sweden',
source: 'Korea Republic',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '32',
target: 'Mexico',
source: 'Korea Republic',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '33',
target: 'Germany',
source: 'Sweden',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '34',
target: 'Korea Republic',
source: 'Germany',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '35',
target: 'Sweden',
source: 'Mexico',
data: {
target_score: 3,
source_score: 0,
directed: true,
},
},
{
id: '36',
target: 'Belgium',
source: 'Panama',
data: {
target_score: 3,
source_score: 0,
directed: true,
},
},
{
id: '37',
target: 'England',
source: 'Tunisia',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '38',
target: 'Belgium',
source: 'Tunisia',
data: {
target_score: 5,
source_score: 2,
directed: true,
},
},
{
id: '39',
target: 'England',
source: 'Panama',
data: {
target_score: 6,
source_score: 1,
directed: true,
},
},
{
id: '40',
target: 'Belgium',
source: 'England',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '41',
target: 'Tunisia',
source: 'Panama',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '42',
target: 'Japan',
source: 'Colombia',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '43',
target: 'Senegal',
source: 'Poland',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '44',
target: 'Japan',
source: 'Senegal',
data: {
target_score: 2,
source_score: 2,
directed: false,
},
},
{
id: '45',
target: 'Colombia',
source: 'Poland',
data: {
target_score: 3,
source_score: 0,
directed: true,
},
},
{
id: '46',
target: 'Poland',
source: 'Japan',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '47',
target: 'Colombia',
source: 'Senegal',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '48',
target: 'Uruguay',
source: 'Portugal',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '49',
target: 'France',
source: 'Argentina',
data: {
target_score: 4,
source_score: 3,
directed: true,
},
},
{
id: '50',
target: 'Russia',
source: 'Spain',
data: {
target_score: 5,
source_score: 4,
directed: true,
},
},
{
id: '51',
target: 'Croatia',
source: 'Denmark',
data: {
target_score: 4,
source_score: 3,
directed: true,
},
},
{
id: '52',
target: 'Brazil',
source: 'Mexico',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '53',
target: 'Belgium',
source: 'Japan',
data: {
target_score: 3,
source_score: 2,
directed: true,
},
},
{
id: '54',
target: 'Sweden',
source: 'Switzerland',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '55',
target: 'England',
source: 'Colombia',
data: {
target_score: 4,
source_score: 3,
directed: true,
},
},
{
id: '56',
target: 'France',
source: 'Uruguay',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '57',
target: 'Belgium',
source: 'Brazil',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '58',
target: 'Croatia',
source: 'Russia',
data: {
target_score: 6,
source_score: 5,
directed: true,
},
},
{
id: '59',
target: 'England',
source: 'Sweden',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '60',
target: 'France',
source: 'Belgium',
data: {
target_score: 1,
source_score: 0,
directed: true,
},
},
{
id: '61',
target: 'Croatia',
source: 'England',
data: {
target_score: 2,
source_score: 1,
directed: true,
},
},
{
id: '62',
target: 'Belgium',
source: 'England',
data: {
target_score: 2,
source_score: 0,
directed: true,
},
},
{
id: '63',
target: 'France',
source: 'Croatia',
data: {
target_score: 4,
source_score: 2,
directed: true,
},
},
],
};
export { data };

View File

@ -0,0 +1,271 @@
import { Graph, Layout, LayoutMapping } from '@antv/layout';
import G6, { IGraph, stdLib } from '../../src/index';
import { data } from '../datasets/dataset1';
const container = document.createElement('div');
document.querySelector('body').appendChild(container);
describe('layout', () => {
let graph: IGraph<any>;
it('should apply circular layout correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
graph.destroy();
done();
});
});
it('should trigger re-layout by calling `layout` method manually.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
// first-time layout
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
// re-layout
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(350);
expect(nodesData[0].data.y).toBe(250);
graph.destroy();
done();
});
graph.layout({
type: 'circular',
center: [250, 250],
radius: 100, // change radius here
});
});
});
it('should trigger re-layout by calling `changeData` method manually.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
// first-time layout
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
// re-layout
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(250);
expect(nodesData[0].data.y).toBe(250);
graph.destroy();
done();
});
// Only one single node.
const newData = {
nodes: [{ id: 'node13', data: { x: 50, y: 50 } }],
edges: [{ id: 'edge1', source: 'node13', target: 'node13', data: {} }],
};
graph.changeData(newData);
});
});
it('should run layout in WebWorker with `workerEnabled`.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
workerEnabled: true,
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
// re-layout
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].data.x).toBe(350);
expect(nodesData[0].data.y).toBe(250);
graph.destroy();
done();
});
graph.layout({
type: 'circular',
center: [250, 250],
radius: 100, // change radius here
});
});
});
it('should display the layout process with `animated`.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'd3force',
animated: true,
center: [250, 250],
preventOverlap: true,
nodeSize: 20,
},
});
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData.every((node) => node.data.x > 0 && node.data.y > 0)).toBeTruthy();
graph.destroy();
done();
});
});
it('should stop animated layout process with `stopLayout`.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'd3force',
animated: true,
center: [250, 250],
preventOverlap: true,
nodeSize: 20,
},
});
setTimeout(() => {
graph.stopLayout();
const nodesData = graph.getAllNodesData();
expect(nodesData.every((node) => node.data.x > 0 && node.data.y > 0)).toBeTruthy();
graph.destroy();
done();
}, 1000);
});
it('should manually steps the simulation with `iterations` and `animated` disabled.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'd3force',
animated: false,
center: [250, 250],
preventOverlap: true,
nodeSize: 20,
iterations: 1000,
},
});
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData.every((node) => node.data.x > 0 && node.data.y > 0)).toBeTruthy();
graph.destroy();
done();
});
});
it('should allow registering custom layout at runtime.', (done) => {
// Put all nodes at `[0, 0]`.
class MyCustomLayout implements Layout<{}> {
async assign(graph: Graph, options?: {}): Promise<void> {
throw new Error('Method not implemented.');
}
async execute(graph: Graph, options?: {}): Promise<LayoutMapping> {
const nodes = graph.getAllNodes();
return {
nodes: nodes.map((node) => ({
id: node.id,
data: {
x: 0,
y: 0,
},
})),
edges: [],
};
}
options: {};
id: 'myCustomLayout';
}
// Register custom layout
stdLib.layouts['myCustomLayout'] = MyCustomLayout;
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
// @ts-ignore
type: 'myCustomLayout',
},
});
graph.once('afterlayout', () => {
const nodesData = graph.getAllNodesData();
expect(nodesData.every((node) => node.data.x === 0 && node.data.y === 0)).toBeTruthy();
graph.destroy();
done();
});
});
});