import type { IAnimation } from '@antv/g';
import { Graph as Graphlib } from '@antv/graphlib';
import { Supervisor, isLayoutWithIterations } from '@antv/layout';
import { deepMix } from '@antv/util';
import { COMBO_KEY, GraphEvent, TREE_KEY } from '../constants';
import { BaseLayout } from '../layouts';
import type { AntVLayout } from '../layouts/types';
import { getExtension } from '../registry/get';
import type { GraphData, LayoutOptions, NodeData } from '../spec';
import type { STDLayoutOptions } from '../spec/layout';
import type { DrawData } from '../transforms/types';
import type { AdaptiveLayout, ID, TreeData } from '../types';
import { getAnimationOptions } from '../utils/animation';
import { isCollapsed } from '../utils/collapsibility';
import { isToBeDestroyed } from '../utils/element';
import { GraphLifeCycleEvent, emit } from '../utils/event';
import { createTreeStructure } from '../utils/graphlib';
import { idOf } from '../utils/id';
import { isTreeLayout, layoutAdapter, layoutMapping2GraphData } from '../utils/layout';
import { print } from '../utils/print';
import { dfs } from '../utils/traverse';
import type { RuntimeContext } from './types';
export class LayoutController {
private context: RuntimeContext;
private supervisor?: Supervisor;
private instance?: BaseLayout;
private instances: BaseLayout[] = [];
private animationResult?: IAnimation | null;
private get presetOptions() {
return {
animation: !!getAnimationOptions(this.context.options, true),
};
}
private get options() {
const { options } = this.context;
return options.layout;
}
constructor(context: RuntimeContext) {
this.context = context;
}
public getLayoutInstance(): BaseLayout[] {
return this.instances;
}
/**
* 前布局,即在绘制前执行布局
*
* Pre-layout, that is, perform layout before drawing
* @param data - 绘制数据 | Draw data
* @remarks
* 前布局应该只在首次绘制前执行,后续更新不会触发
*
* Pre-layout should only be executed before the first drawing, and subsequent updates will not trigger
*/
public async preLayout(data: DrawData) {
const { graph, model } = this.context;
const { add } = data;
emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_LAYOUT, { type: 'pre' }));
const simulate = await this.context.layout?.simulate();
simulate?.nodes?.forEach((l) => {
const id = idOf(l);
const node = add.nodes.get(id);
model.syncNodeLikeDatum(l);
if (node) Object.assign(node.style!, l.style);
});
simulate?.edges?.forEach((l) => {
const id = idOf(l);
const edge = add.edges.get(id);
model.syncEdgeDatum(l);
if (edge) Object.assign(edge.style!, l.style);
});
simulate?.combos?.forEach((l) => {
const id = idOf(l);
const combo = add.combos.get(id);
model.syncNodeLikeDatum(l);
if (combo) Object.assign(combo.style!, l.style);
});
emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_LAYOUT, { type: 'pre' }));
this.transformDataAfterLayout('pre', data);
}
/**
* 后布局,即在完成绘制后执行布局
*
* Post layout, that is, perform layout after drawing
* @param layoutOptions - 布局配置项 | Layout options
*/
public async postLayout(layoutOptions: LayoutOptions | undefined = this.options) {
if (!layoutOptions) return;
const pipeline = Array.isArray(layoutOptions) ? layoutOptions : [layoutOptions];
const { graph } = this.context;
emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_LAYOUT, { type: 'post' }));
for (let index = 0; index < pipeline.length; index++) {
const options = pipeline[index];
const data = this.getLayoutData(options);
const opts = { ...this.presetOptions, ...options };
emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_STAGE_LAYOUT, { options: opts, index }));
const result = await this.stepLayout(data, opts, index);
emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_STAGE_LAYOUT, { options: opts, index }));
if (!options.animation) {
this.updateElementPosition(result, false);
}
}
emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_LAYOUT, { type: 'post' }));
this.transformDataAfterLayout('post');
}
private transformDataAfterLayout(type: 'pre' | 'post', data?: DrawData) {
const transforms = this.context.transform.getTransformInstance();
// @ts-expect-error skip type check
Object.values(transforms).forEach((transform) => transform.afterLayout(type, data));
}
/**
* 模拟布局
*
* Simulate layout
* @returns 模拟布局结果 | Simulated layout result
*/
public async simulate(): Promise {
if (!this.options) return {};
const pipeline = Array.isArray(this.options) ? this.options : [this.options];
let simulation: GraphData = {};
for (let index = 0; index < pipeline.length; index++) {
const options = pipeline[index];
const data = this.getLayoutData(options);
const result = await this.stepLayout(data, { ...this.presetOptions, ...options, animation: false }, index);
simulation = result;
}
return simulation;
}
public async stepLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise {
if (isTreeLayout(options)) return await this.treeLayout(data, options, index);
return await this.graphLayout(data, options, index);
}
private async graphLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise {
const { animation, enableWorker, iterations = 300 } = options;
const layout = this.initGraphLayout(options);
if (!layout) return {};
this.instances[index] = layout;
this.instance = layout;
// 使用 web worker 执行布局 / Use web worker to execute layout
if (enableWorker) {
const rawLayout = layout as unknown as AdaptiveLayout;
this.supervisor = new Supervisor(rawLayout.graphData2LayoutModel(data), rawLayout.instance, { iterations });
return layoutMapping2GraphData(await this.supervisor.execute());
}
if (isLayoutWithIterations(layout)) {
// 有动画,基于布局迭代 tick 更新位置 / Update position based on layout iteration tick
if (animation) {
return await layout.execute(data, {
onTick: (tickData: GraphData) => {
this.updateElementPosition(tickData, false);
},
});
}
// 无动画,直接返回终态位置 / No animation, return final position directly
layout.execute(data);
layout.stop();
return layout.tick(iterations);
}
// 无迭代的布局,直接返回终态位置 / Layout without iteration, return final position directly
const layoutResult = await layout.execute(data);
if (animation) {
const animationResult = this.updateElementPosition(layoutResult, animation);
await animationResult?.finished;
}
return layoutResult;
}
private async treeLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise {
const { type, animation } = options;
// @ts-expect-error @antv/hierarchy 布局格式与 @antv/layout 不一致,其导出的是一个方法,而非 class
// The layout format of @antv/hierarchy is inconsistent with @antv/layout, it exports a method instead of a class
const layout = getExtension('layout', type) as (tree: TreeData, options: STDLayoutOptions) => TreeData;
if (!layout) return {};
const { nodes = [], edges = [] } = data;
const model = new Graphlib({
nodes: nodes.map((node) => ({ id: idOf(node), data: node.data || {} })),
edges: edges.map((edge) => ({ id: idOf(edge), source: edge.source, target: edge.target, data: edge.data || {} })),
});
createTreeStructure(model);
const layoutPreset: GraphData = { nodes: [], edges: [] };
const layoutResult: GraphData = { nodes: [], edges: [] };
const roots = model.getRoots(TREE_KEY) as unknown as TreeData[];
roots.forEach((root) => {
dfs(
root,
(node) => {
node.children = model.getSuccessors(node.id) as TreeData[];
},
(node) => model.getSuccessors(node.id) as TreeData[],
'TB',
);
const result = layout(root, options);
const { x: rx, y: ry, z: rz = 0 } = result;
// 将布局结果转化为 LayoutMapping 格式 / Convert the layout result to LayoutMapping format
dfs(
result,
(node) => {
const { id, x, y, z = 0 } = node;
layoutPreset.nodes!.push({ id, style: { x: rx, y: ry, z: rz } });
layoutResult.nodes!.push({ id, style: { x, y, z } });
},
(node) => node.children,
'TB',
);
});
const offset = this.inferTreeLayoutOffset(layoutResult);
applyTreeLayoutOffset(layoutResult, offset);
if (animation) {
// 先将所有节点移动到根节点位置 / Move all nodes to the root node position first
applyTreeLayoutOffset(layoutPreset, offset);
this.updateElementPosition(layoutPreset, false);
const animationResult = this.updateElementPosition(layoutResult, animation);
await animationResult?.finished;
}
return layoutResult;
}
private inferTreeLayoutOffset(data: GraphData) {
let [minX, maxX] = [Infinity, -Infinity];
let [minY, maxY] = [Infinity, -Infinity];
data.nodes?.forEach((node) => {
const { x = 0, y = 0 } = node.style || {};
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
});
const { canvas } = this.context;
const canvasSize = canvas.getSize();
const [x1, y1] = canvas.getCanvasByViewport([0, 0]);
const [x2, y2] = canvas.getCanvasByViewport(canvasSize);
if (minX >= x1 && maxX <= x2 && minY >= y1 && maxY <= y2) return [0, 0] as [number, number];
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return [cx - (minX + maxX) / 2, cy - (minY + maxY) / 2] as [number, number];
}
public stopLayout() {
if (this.instance && isLayoutWithIterations(this.instance)) {
this.instance.stop();
this.instance = undefined;
}
if (this.supervisor) {
this.supervisor.stop();
this.supervisor = undefined;
}
if (this.animationResult) {
this.animationResult.finish();
this.animationResult = undefined;
}
}
public getLayoutData(options: STDLayoutOptions): GraphData {
const { nodeFilter = () => true, preLayout = false, isLayoutInvisibleNodes = false } = options;
const { nodes, edges, combos } = this.context.model.getData();
const { element, model } = this.context;
const getElement = (id: ID) => element!.getElement(id);
const filterFn = preLayout
? (node: NodeData) => {
if (!isLayoutInvisibleNodes) {
if (node.style?.visibility === 'hidden') return false;
if (model.getAncestorsData(node.id, TREE_KEY).some(isCollapsed)) return false;
if (model.getAncestorsData(node.id, COMBO_KEY).some(isCollapsed)) return false;
}
return nodeFilter(node);
}
: (node: NodeData) => {
const id = idOf(node);
const element = getElement(id);
if (!element) return false;
if (isToBeDestroyed(element)) return false;
return nodeFilter(node);
};
const nodesToLayout = nodes.filter(filterFn);
const nodeLikeIdsMap = new Map(nodesToLayout.map((node) => [idOf(node), node]));
combos.forEach((combo) => nodeLikeIdsMap.set(idOf(combo), combo));
const edgesToLayout = edges.filter(({ source, target }) => {
return nodeLikeIdsMap.has(source) && nodeLikeIdsMap.has(target);
});
return {
nodes: nodesToLayout,
edges: edgesToLayout,
combos,
};
}
/**
* 创建布局实例
*
* Create layout instance
* @param options - 布局配置项 | Layout options
* @returns 布局对象 | Layout object
*/
private initGraphLayout(options: STDLayoutOptions) {
const { element, viewport } = this.context;
const { type, enableWorker, animation, iterations, ...restOptions } = options;
const [width, height] = viewport!.getCanvasSize();
const center = [width / 2, height / 2];
const nodeSize: number | ((node: NodeData) => number) =
(options?.nodeSize as number) ??
((node) => {
const nodeElement = element?.getElement(node.id);
if (nodeElement) return nodeElement.attributes.size;
return element?.getElementComputedStyle('node', node).size;
});
const Ctor = getExtension('layout', type);
if (!Ctor) return print.warn(`The layout of ${type} is not registered.`);
const STDCtor =
Object.getPrototypeOf(Ctor.prototype) === BaseLayout.prototype
? Ctor
: layoutAdapter(Ctor as new (options?: Record) => AntVLayout, this.context);
const layout = new STDCtor(this.context);
const config = { nodeSize, width, height, center };
switch (layout.id) {
case 'd3-force':
case 'd3-force-3d':
Object.assign(config, {
center: { x: width / 2, y: height / 2, z: 0 },
});
break;
default:
break;
}
deepMix(layout.options, config, restOptions);
return layout as unknown as BaseLayout;
}
private updateElementPosition(layoutResult: GraphData, animation: boolean) {
const { model, element } = this.context;
if (!element) return null;
model.updateData(layoutResult);
return element.draw({ animation, silence: true });
}
public destroy() {
this.stopLayout();
// @ts-expect-error force delete
this.context = {};
this.supervisor?.kill();
this.supervisor = undefined;
this.instance = undefined;
this.instances = [];
this.animationResult = undefined;
}
}
/**
* 对树形布局结果应用偏移
*
* Apply offset to tree layout result
* @param data - 布局数据 | Layout data
* @param offset - 偏移量 | Offset
*/
const applyTreeLayoutOffset = (data: GraphData, offset: [number, number]) => {
const [ox, oy] = offset;
data.nodes?.forEach((node) => {
if (node.style) {
const { x = 0, y = 0 } = node.style;
node.style.x = x + ox;
node.style.y = y + oy;
} else {
node.style = { x: ox, y: oy };
}
});
};