From 8aa3618ea2dec9dc80ea579f359927ec786f51bf Mon Sep 17 00:00:00 2001 From: xiaoiver Date: Tue, 14 Mar 2023 14:51:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9B=B4=E5=A4=9A=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=B8=8B=E7=9A=84=E5=B8=83=E5=B1=80=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=20(#4345)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support transition on layouts without iterations #4339 * feat: use immediately invoked layout as a shortcut #4339 * feat: handle scenario when layout is unset in spec * fix: disable css parsing for better performance --- packages/g6/src/runtime/controller/layout.ts | 216 +++++++++++++----- packages/g6/src/runtime/graph.ts | 78 +++++-- packages/g6/src/types/index.ts | 9 +- packages/g6/src/types/layout.ts | 93 ++++++-- packages/g6/tests/unit/layout-spec.ts | 227 ++++++++++++++++++- 5 files changed, 525 insertions(+), 98 deletions(-) diff --git a/packages/g6/src/runtime/controller/layout.ts b/packages/g6/src/runtime/controller/layout.ts index c5661f92e6..88d477817c 100644 --- a/packages/g6/src/runtime/controller/layout.ts +++ b/packages/g6/src/runtime/controller/layout.ts @@ -1,8 +1,13 @@ -import { isLayoutWithIterations, Layout, LayoutMapping, Supervisor } from '@antv/layout'; +import { Animation, DisplayObject, IAnimationEffectTiming } from '@antv/g'; +import { isLayoutWithIterations, Layout, LayoutMapping, OutNode, Supervisor } from '@antv/layout'; import { stdLib } from '../../stdlib'; -import { IGraph } from '../../types'; +import { + IGraph, + isImmediatelyInvokedLayoutOptions, + isLayoutWorkerized, + LayoutOptions, +} from '../../types'; import { GraphCore } from '../../types/data'; -import { LayoutOptions } from '../../types/layout'; /** * Manages layout extensions and graph layout. @@ -12,11 +17,14 @@ export class LayoutController { public extensions = {}; public graph: IGraph; - private currentLayout: Layout; - private currentSupervisor: Supervisor; + private currentLayout: Layout | null; + private currentSupervisor: Supervisor | null; + private currentAnimation: Animation | null; + private animatedDisplayObject: DisplayObject; constructor(graph: IGraph) { this.graph = graph; + this.animatedDisplayObject = new DisplayObject({}); this.tap(); } @@ -27,67 +35,98 @@ export class LayoutController { this.graph.hooks.layout.tap(this.onLayout.bind(this)); } - private async onLayout(params: { graphCore: GraphCore; options?: LayoutOptions }) { + private async onLayout(params: { graphCore: GraphCore; options: LayoutOptions }) { + /** + * The final calculated result. + */ + let positions: LayoutMapping; + // Stop currentLayout if any. this.stopLayout(); const { graphCore, options } = params; - const { - type, - workerEnabled, - animated, - iterations = 300, - ...rest - } = { - ...this.graph.getSpecification().layout, - ...options, - }; + if (isImmediatelyInvokedLayoutOptions(options)) { + const { + animated = false, + animationEffectTiming = { + duration: 1000, + } as Partial, + execute, + ...rest + } = options; - // Find built-in layout algorithms. - const layoutCtor = stdLib.layouts[type]; - if (!layoutCtor) { - throw new Error(`Unknown layout algorithm: ${type}`); - } + // It will ignore some layout options such as `type` and `workerEnabled`. + positions = await execute(graphCore, rest); - // 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(); + if (animated) { + await this.animateLayoutWithoutIterations(positions, animationEffectTiming); + } } 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); - } + const { + type = 'grid', + animated = false, + animationEffectTiming = { + duration: 1000, + } as Partial, + iterations = 300, + ...rest + } = options; + let { workerEnabled = false } = 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; + + // CustomLayout is not workerized. + if (!isLayoutWorkerized(options)) { + workerEnabled = false; + // TODO: console.warn(); + } + + if (workerEnabled) { /** - * `onTick` will get triggered in this case. + * Run algorithm in WebWorker, `animated` option will be ignored. */ + const supervisor = new Supervisor(graphCore, layout, { iterations }); + this.currentSupervisor = supervisor; + positions = await supervisor.execute(); } else { - positions = await layout.execute(graphCore); + // e.g. Force layout + if (isLayoutWithIterations(layout)) { + if (animated) { + /** + * `onTick` will get triggered in this case. + */ + 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); + } + } else { + positions = await layout.execute(graphCore); + + if (animated) { + await this.animateLayoutWithoutIterations(positions, animationEffectTiming); + } + } } } @@ -105,6 +144,11 @@ export class LayoutController { this.currentSupervisor.stop(); this.currentSupervisor = null; } + + if (this.currentAnimation) { + this.currentAnimation.finish(); + this.currentAnimation = null; + } } destroy() { @@ -126,4 +170,68 @@ export class LayoutController { }); }); } + + /** + * For those layout without iterations, e.g. circular, random, since they don't have `onTick` callback, + * we have to translate each node from its initial position to final one after layout, + * with the help of `onframe` from G's animation API. + * @param positions + * @param animationEffectTiming + */ + private async animateLayoutWithoutIterations( + positions: LayoutMapping, + animationEffectTiming: Partial, + ) { + // Animation should be executed only once. + animationEffectTiming.iterations = 1; + + const initialPositions = positions.nodes.map((node) => { + // Should clone each node's initial data since it will be updated during the layout process. + return { ...this.graph.getNodeData(`${node.id}`)! }; + }); + + // Add a connected displayobject so that we can animate it. + if (!this.animatedDisplayObject.isConnected) { + await this.graph.canvas.ready; + this.graph.canvas.appendChild(this.animatedDisplayObject); + } + + // Use `opacity` since it is an interpolated property. + this.currentAnimation = this.animatedDisplayObject.animate( + [ + { + opacity: 0, + }, + { + opacity: 1, + }, + ], + animationEffectTiming, + ) as Animation; + + // Update each node's position on each frame. + // @see https://g.antv.antgroup.com/api/animation/waapi#%E5%B1%9E%E6%80%A7 + this.currentAnimation.onframe = (e) => { + // @see https://g.antv.antgroup.com/api/animation/waapi#progress + const progress = (e.target as Animation).effect.getComputedTiming().progress as number; + const interpolatedNodesPosition = (initialPositions as OutNode[]).map(({ id, data }, i) => { + const { x: fromX = 0, y: fromY = 0 } = data; + const { x: toX, y: toY } = positions.nodes[i].data; + return { + id, + data: { + x: fromX + (toX - fromX) * progress, + y: fromY + (toY - fromY) * progress, + }, + }; + }); + + this.updateNodesPosition({ + nodes: interpolatedNodesPosition, + edges: [], + }); + }; + + await this.currentAnimation.finished; + } } diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 262cf94ee0..539e12a2d1 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -1,7 +1,7 @@ import EventEmitter from '@antv/event-emitter'; -import { Canvas } from '@antv/g'; +import { Canvas, runtime } from '@antv/g'; import { GraphChange, ID } from '@antv/graphlib'; -import { isArray, isNumber, isObject, isString } from '@antv/util'; +import { isArray, isNil, isNumber, isObject, isString } from '@antv/util'; import { ComboUserModel, EdgeUserModel, @@ -18,20 +18,29 @@ import { GraphCore } from '../types/data'; import { EdgeModel, EdgeModelData } from '../types/edge'; import { Hooks } from '../types/hook'; import { ITEM_TYPE } from '../types/item'; -import { LayoutOptions } from '../types/layout'; +import { + ImmediatelyInvokedLayoutOptions, + LayoutOptions, + StandardLayoutOptions, +} from '../types/layout'; import { NodeModel, NodeModelData } from '../types/node'; import { FitViewRules, GraphAlignment } from '../types/view'; import { createCanvas } from '../util/canvas'; import { DataController, + ExtensionController, InteractionController, ItemController, LayoutController, ThemeController, - ExtensionController, } from './controller'; import Hook from './hooks'; +/** + * Disable CSS parsing for better performance. + */ +runtime.enableCSSParsing = false; + export default class Graph extends EventEmitter implements IGraph { public hooks: Hooks; // for nodes and edges, which will be separate into groups @@ -92,7 +101,9 @@ export default class Graph extends EventEmitter impl } this.backgroundCanvas = createCanvas(rendererType, container, width, height, pixelRatio); this.canvas = createCanvas(rendererType, container, width, height, pixelRatio); - this.transientCanvas = createCanvas(rendererType, container, width, height, pixelRatio, true, { pointerEvents: 'none' }); + this.transientCanvas = createCanvas(rendererType, container, width, height, pixelRatio, true, { + pointerEvents: 'none', + }); Promise.all( [this.backgroundCanvas, this.canvas, this.transientCanvas].map((canvas) => canvas.ready), ).then(() => (this.canvasReady = true)); @@ -118,7 +129,9 @@ export default class Graph extends EventEmitter impl modes: string[]; behaviors: BehaviorOptionsOf<{}>[]; }>({ name: 'behaviorchange' }), - itemstatechange: new Hook<{ ids: ID[], state: string, value: boolean }>({ name: 'itemstatechange' }) + itemstatechange: new Hook<{ ids: ID[]; state: string; value: boolean }>({ + name: 'itemstatechange', + }), }; } @@ -152,12 +165,7 @@ export default class Graph extends EventEmitter impl }); this.emit('afterrender'); - // TODO: make read async? - await this.hooks.layout.emitLinearAsync({ - graphCore: this.dataController.graphCore, - }); - - this.emit('afterlayout'); + await this.layout(); }; if (this.canvasReady) { await emitRender(); @@ -183,11 +191,7 @@ export default class Graph extends EventEmitter impl }); this.emit('afterrender'); - await this.hooks.layout.emitLinearAsync({ - graphCore: this.dataController.graphCore, - }); - - this.emit('afterlayout'); + await this.layout(); } /** @@ -351,7 +355,7 @@ export default class Graph extends EventEmitter impl let ids = this.itemController.findIdByState(itemType, state, value); if (additionalFilter) { const getDataFunc = itemType === 'node' ? this.getNodeData : this.getEdgeData; // TODO: combo - ids = ids.filter(id => additionalFilter(getDataFunc(id))); + ids = ids.filter((id) => additionalFilter(getDataFunc(id))); } return ids; } @@ -498,7 +502,7 @@ export default class Graph extends EventEmitter impl this.hooks.itemstatechange.emit({ ids: idArr as ID[], states: stateArr as string[], - value + value, }); } /** @@ -524,7 +528,7 @@ export default class Graph extends EventEmitter impl this.hooks.itemstatechange.emit({ ids: idArr as ID[], states, - value: false + value: false, }); } @@ -568,9 +572,39 @@ export default class Graph extends EventEmitter impl * Layout the graph (with current configurations if cfg is not assigned). */ public async layout(options?: LayoutOptions) { + const { graphCore } = this.dataController; + const formattedOptions = { + ...this.getSpecification().layout, + ...options, + } as LayoutOptions; + + const layoutUnset = !options && !this.getSpecification().layout; + if (layoutUnset) { + const nodes = graphCore.getAllNodes(); + if (nodes.every((node) => isNil(node.data.x) && isNil(node.data.y))) { + // Use `grid` layout as default when x/y of each node is unset. + (formattedOptions as StandardLayoutOptions).type = 'grid'; + } else { + // Use user-defined position(x/y default to 0). + (formattedOptions as ImmediatelyInvokedLayoutOptions).execute = async (graph) => { + const nodes = graph.getAllNodes(); + return { + nodes: nodes.map((node) => ({ + id: node.id, + data: { + x: Number(node.data.x) || 0, + y: Number(node.data.y) || 0, + }, + })), + edges: [], + }; + }; + } + } + await this.hooks.layout.emitLinearAsync({ - graphCore: this.dataController.graphCore, - options, + graphCore, + options: formattedOptions, }); this.emit('afterlayout'); } diff --git a/packages/g6/src/types/index.ts b/packages/g6/src/types/index.ts index 1ca7e76e60..4869268ef3 100644 --- a/packages/g6/src/types/index.ts +++ b/packages/g6/src/types/index.ts @@ -3,4 +3,11 @@ export { GraphData } from './data'; export { NodeUserModel, NodeModel, NodeDisplayModel } from './node'; export { EdgeUserModel, EdgeModel, EdgeDisplayModel } from './edge'; export { ComboUserModel, ComboModel, ComboDisplayModel } from './combo'; -export { Specification } from './spec'; \ No newline at end of file +export { Specification } from './spec'; +export { + StandardLayoutOptions, + ImmediatelyInvokedLayoutOptions, + LayoutOptions, + isImmediatelyInvokedLayoutOptions, + isLayoutWorkerized, +} from './layout'; diff --git a/packages/g6/src/types/layout.ts b/packages/g6/src/types/layout.ts index 8ef2b2aa83..e58bb8cbac 100644 --- a/packages/g6/src/types/layout.ts +++ b/packages/g6/src/types/layout.ts @@ -1,3 +1,4 @@ +import { IAnimationEffectTiming } from '@antv/g'; import { CircularLayoutOptions, ConcentricLayoutOptions, @@ -6,28 +7,94 @@ import { ForceLayoutOptions, FruchtermanLayoutOptions, GridLayoutOptions, + LayoutMapping, MDSLayoutOptions, RadialLayoutOptions, RandomLayoutOptions, } from '@antv/layout'; +import { GraphCore } from './data'; -export type LayoutOptions = ( - | CircularLayout - | RandomLayout - | ConcentricLayout - | GridLayout - | MDSLayout - | RadialLayout - | FruchtermanLayout - | D3ForceLayout - | ForceLayout - | ForceAtlas2 -) & { - workerEnabled?: boolean; +type Animatable = { + /** + * Make layout animated. For layouts with iterations, transitions will happen between ticks. + */ animated?: boolean; + + /** + * Effect timing of animation for layouts without iterations. + * @see https://g.antv.antgroup.com/api/animation/waapi#effecttiming + */ + animationEffectTiming?: Partial; +}; + +type Workerized = { + /** + * Make layout running in WebWorker. + */ + workerEnabled?: boolean; + + /** + * Iterations for iteratable layouts such as Force. + */ iterations?: number; }; +export type ImmediatelyInvokedLayoutOptions = { + /** + * like an IIFE. + */ + execute: (graph: GraphCore, options?: any) => Promise; +} & Animatable; + +type CustomLayout = { + type: string; + [option: string]: any; +}; + +export type StandardLayoutOptions = + | ( + | CircularLayout + | RandomLayout + | ConcentricLayout + | GridLayout + | MDSLayout + | RadialLayout + | FruchtermanLayout + | D3ForceLayout + | ForceLayout + | ForceAtlas2 + | CustomLayout + ) & + Animatable & + Workerized; + +export type LayoutOptions = StandardLayoutOptions | ImmediatelyInvokedLayoutOptions; + +export function isImmediatelyInvokedLayoutOptions( + options: any, +): options is ImmediatelyInvokedLayoutOptions { + return !!options.execute; +} + +export function isLayoutWorkerized(options: StandardLayoutOptions) { + return ( + [ + 'circular', + 'random', + 'grid', + 'mds', + 'concentric', + 'radial', + 'fruchterman', + 'fruchtermanGPU', + 'd3force', + 'force', + 'gforce', + 'forceAtlas2', + ].indexOf(options.type) > -1 + ); +} + interface CircularLayout extends CircularLayoutOptions { type: 'circular'; } diff --git a/packages/g6/tests/unit/layout-spec.ts b/packages/g6/tests/unit/layout-spec.ts index 8b262b3815..47cc3be01f 100644 --- a/packages/g6/tests/unit/layout-spec.ts +++ b/packages/g6/tests/unit/layout-spec.ts @@ -1,11 +1,123 @@ -import { Graph, Layout, LayoutMapping } from '@antv/layout'; -import G6, { IGraph, stdLib } from '../../src/index'; +import { Layout, LayoutMapping } from '@antv/layout'; +import G6, { stdLib } from '../../src/index'; import { data } from '../datasets/dataset1'; const container = document.createElement('div'); -document.querySelector('body').appendChild(container); +document.querySelector('body')!.appendChild(container); describe('layout', () => { - let graph: IGraph; + let graph: any; + + it('should use grid as default when layout is unset in spec.', (done) => { + graph = new G6.Graph({ + container, + width: 500, + height: 500, + type: 'graph', + data, + }); + + graph.once('afterlayout', () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].data.x).toBe(125); + expect(nodesData[0].data.y).toBe(75); + + expect(nodesData[1].data.x).toBe(225); + expect(nodesData[1].data.y).toBe(125); + + graph.destroy(); + done(); + }); + }); + + it("should use user-defined x/y as node's position when layout is unset in spec.", (done) => { + setTimeout(() => { + graph = new G6.Graph({ + container, + width: 500, + height: 500, + type: 'graph', + data: { + nodes: [ + { + id: 'a', + data: { + x: 100, + y: 100, + }, + }, + { + id: 'b', + data: { + x: 100, + }, + }, + { + id: 'c', + data: {}, + }, + ], + edges: [], + }, + }); + + graph.once('afterlayout', async () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].data.x).toBe(100); + expect(nodesData[0].data.y).toBe(100); + expect(nodesData[1].data.x).toBe(100); + expect(nodesData[1].data.y).toBe(0); + expect(nodesData[2].data.x).toBe(0); + expect(nodesData[2].data.y).toBe(0); + + // re-layout + await graph.layout({ + execute: async () => { + return { + nodes: [ + { + id: 'a', + data: { + x: 200, + y: 200, + }, + }, + { + id: 'b', + data: { + x: 100, + y: 100, + }, + }, + { + id: 'c', + data: { + x: 250, + y: 250, + }, + }, + ], + edges: [], + }; + }, + animated: true, + animationEffectTiming: { + duration: 1000, + }, + }); + + expect(nodesData[0].data.x).toBe(200); + expect(nodesData[0].data.y).toBe(200); + expect(nodesData[1].data.x).toBe(100); + expect(nodesData[1].data.y).toBe(100); + expect(nodesData[2].data.x).toBe(250); + expect(nodesData[2].data.y).toBe(250); + + graph.destroy(); + done(); + }); + }, 500); + }); + it('should apply circular layout correctly.', (done) => { graph = new G6.Graph({ container, @@ -145,7 +257,7 @@ describe('layout', () => { }); }); - it('should display the layout process with `animated`.', (done) => { + it('should display the process in layout with iterations when `animated` enabled.', (done) => { graph = new G6.Graph({ container, width: 500, @@ -169,6 +281,106 @@ describe('layout', () => { }); }); + it('should display the process in layout without iterations when `animated` enabled.', (done) => { + setTimeout(() => { + graph = new G6.Graph({ + container, + width: 500, + height: 500, + type: 'graph', + data, + layout: { + type: 'circular', + animated: true, + center: [250, 250], + radius: 200, + }, + }); + + graph.once('afterlayout', async () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].data.x).toBe(450); + expect(nodesData[0].data.y).toBe(250); + + await graph.layout({ + type: 'circular', + center: [250, 250], + radius: 100, + animated: true, + animationEffectTiming: { + duration: 1000, + easing: 'in-out-bounce', + }, + }); + + await graph.layout({ + type: 'random', + animated: true, + animationEffectTiming: { + duration: 1000, + }, + }); + + await graph.layout({ + type: 'circular', + center: [250, 250], + radius: 200, + animated: false, + }); + + graph.destroy(); + done(); + }); + }, 500); + }); + + it('should execute an immediately invoked layout with animation.', (done) => { + setTimeout(() => { + graph = new G6.Graph({ + container, + width: 500, + height: 500, + type: 'graph', + data, + layout: { + type: 'circular', + animated: true, + center: [250, 250], + radius: 200, + }, + }); + + graph.once('afterlayout', async () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].data.x).toBe(450); + expect(nodesData[0].data.y).toBe(250); + + await graph.layout({ + execute: async (graph) => { + const nodes = graph.getAllNodes(); + return { + nodes: nodes.map((node) => ({ + id: node.id, + data: { + x: 250, + y: 250, + }, + })), + edges: [], + }; + }, + animated: true, + animationEffectTiming: { + duration: 1000, + }, + }); + + graph.destroy(); + done(); + }); + }, 500); + }); + it('should stop animated layout process with `stopLayout`.', (done) => { graph = new G6.Graph({ container, @@ -225,10 +437,10 @@ describe('layout', () => { 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 { + async assign(graph, options?: {}): Promise { throw new Error('Method not implemented.'); } - async execute(graph: Graph, options?: {}): Promise { + async execute(graph, options?: {}): Promise { const nodes = graph.getAllNodes(); return { nodes: nodes.map((node) => ({ @@ -255,7 +467,6 @@ describe('layout', () => { type: 'graph', data, layout: { - // @ts-ignore type: 'myCustomLayout', }, });