支持更多场景下的布局过渡动画 (#4345)

* 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
This commit is contained in:
xiaoiver 2023-03-14 14:51:09 +08:00 committed by GitHub
parent e2158e8592
commit 8aa3618ea2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 525 additions and 98 deletions

View File

@ -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<any>;
private currentSupervisor: Supervisor;
private currentLayout: Layout<any> | null;
private currentSupervisor: Supervisor | null;
private currentAnimation: Animation | null;
private animatedDisplayObject: DisplayObject;
constructor(graph: IGraph<any>) {
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<IAnimationEffectTiming>,
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<IAnimationEffectTiming>,
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<IAnimationEffectTiming>,
) {
// 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;
}
}

View File

@ -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<B extends BehaviorRegistry> extends EventEmitter implements IGraph<B> {
public hooks: Hooks;
// for nodes and edges, which will be separate into groups
@ -92,7 +101,9 @@ export default class Graph<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> 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<B extends BehaviorRegistry> extends EventEmitter impl
this.hooks.itemstatechange.emit({
ids: idArr as ID[],
states,
value: false
value: false,
});
}
@ -568,9 +572,39 @@ export default class Graph<B extends BehaviorRegistry> 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');
}

View File

@ -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';
export { Specification } from './spec';
export {
StandardLayoutOptions,
ImmediatelyInvokedLayoutOptions,
LayoutOptions,
isImmediatelyInvokedLayoutOptions,
isLayoutWorkerized,
} from './layout';

View File

@ -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<IAnimationEffectTiming>;
};
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<LayoutMapping>;
} & 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';
}

View File

@ -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<any>;
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<void> {
async assign(graph, options?: {}): Promise<void> {
throw new Error('Method not implemented.');
}
async execute(graph: Graph, options?: {}): Promise<LayoutMapping> {
async execute(graph, options?: {}): Promise<LayoutMapping> {
const nodes = graph.getAllNodes();
return {
nodes: nodes.map((node) => ({
@ -255,7 +467,6 @@ describe('layout', () => {
type: 'graph',
data,
layout: {
// @ts-ignore
type: 'myCustomLayout',
},
});