refactor: support dynamic switch renderer (#6062)

* feat(runtime): graph emit renderer change event

* refactor: refactor canvas and support switch renderer

* fix(3d): remove light on destroy plugin

* refactor(3d): clear cache when renderer change

* test: add switch renderer demo

---------

Co-authored-by: antv <antv@antfin.com>
This commit is contained in:
Aaron 2024-07-19 12:22:46 +08:00 committed by GitHub
parent 6775bbc70c
commit ed6e940876
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 277 additions and 56 deletions

View File

@ -11,9 +11,10 @@
"afterelementtranslate",
"afterelementupdate",
"afterlayout",
"afterstagelayout",
"afterrender",
"afterrendererchange",
"aftersizechange",
"afterstagelayout",
"aftertransform",
"afterviewportanimate",
"antv",
@ -30,9 +31,10 @@
"beforeelementtranslate",
"beforeelementupdate",
"beforelayout",
"beforestagelayout",
"beforerender",
"beforerendererchange",
"beforesizechange",
"beforestagelayout",
"beforetransform",
"beforeviewportanimate",
"bubblesets",

View File

@ -8,3 +8,4 @@ export { massiveElements } from './massive-elements';
export * from './position';
export * from './shapes';
export * from './solar-system';
export { switchRenderer } from './switch-renderer';

View File

@ -0,0 +1,60 @@
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import type { NodeData } from '@antv/g6';
import { ExtensionCategory, Graph, register } from '@antv/g6';
import { Light, Sphere, renderer } from '../../src';
export const switchRenderer: TestCase = async (context) => {
register(ExtensionCategory.PLUGIN, '3d-light', Light);
register(ExtensionCategory.NODE, 'sphere', Sphere);
const nodes: NodeData[] = [{ id: '1' }, { id: '2' }];
const graph = new Graph({
...context,
data: {
nodes,
},
layout: {
type: 'grid',
},
});
await graph.render();
switchRenderer.form = (panel) => {
panel.add({ renderer: '2d' }, 'renderer', ['2d', '3d']).onChange((name: string) => {
if (name === '2d') {
graph.setOptions({
renderer: () => new CanvasRenderer(),
node: {
type: 'circle',
},
plugins: [],
});
} else {
graph.setOptions({
renderer,
node: {
type: 'sphere',
style: {
materialType: 'phong',
},
},
plugins: [
{
type: '3d-light',
directional: {
direction: [0, 0, 1],
},
},
],
});
}
graph.draw();
});
return [];
};
return graph;
};

View File

@ -72,6 +72,8 @@ export class Light extends BasePlugin<LightOptions> {
}
public destroy() {
this.ambient?.remove();
this.directional?.remove();
this.unbindEvents();
super.destroy();
}

View File

@ -1,6 +1,8 @@
import type { Device } from '@antv/g-device-api';
import type { ProceduralGeometry } from '@antv/g-plugin-3d';
let DEVICE: Device;
const GEOMETRY_CACHE = new Map<string, unknown>();
/**
@ -19,6 +21,12 @@ export function createGeometry<T extends ProceduralGeometry<any>>(
Ctor: new (...args: any[]) => T,
style: Record<string, unknown>,
) {
if (!DEVICE) DEVICE = device;
else if (DEVICE !== device) {
DEVICE = device;
GEOMETRY_CACHE.clear();
}
const cacheKey =
type +
'|' +

View File

@ -15,4 +15,8 @@ export class TupleMap<K1, K2, V> {
}
this.map.get(key1)!.set(key2, value);
}
clear() {
this.map.clear();
}
}

View File

@ -6,6 +6,8 @@ import { getCacheKey } from './cache';
import { TupleMap } from './map';
import { createTexture } from './texture';
let PLUGIN: Plugin;
const MATERIAL_CACHE = new TupleMap<symbol, string | TexImageSource | undefined, GMaterial>();
const MATERIAL_MAP = {
@ -25,6 +27,12 @@ const MATERIAL_MAP = {
* @returns <zh/> <en/> material object
*/
export function createMaterial(plugin: Plugin, options: Material, texture?: string | TexImageSource): GMaterial {
if (!PLUGIN) PLUGIN = plugin;
else if (PLUGIN !== plugin) {
PLUGIN = plugin;
MATERIAL_CACHE.clear();
}
const key = getCacheKey(options);
if (MATERIAL_CACHE.has(key, texture)) {

View File

@ -0,0 +1,33 @@
import { Graph } from '@/src';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Renderer as SVGRenderer } from '@antv/g-svg';
import { Renderer as WebGLRenderer } from '@antv/g-webgl';
export const canvasSwitchRenderer: TestCase = async (context) => {
const graph = new Graph({
...context,
data: {
nodes: Array.from({ length: 10 }).map((_, i) => ({ id: `node-${i}` })),
},
layout: {
type: 'grid',
},
});
await graph.render();
canvasSwitchRenderer.form = (panel) => {
panel.add({ renderer: 'canvas' }, 'renderer', ['canvas', 'svg', 'webgl']).onChange((name: string) => {
graph.setOptions({
renderer: () => {
if (name === 'svg') return new SVGRenderer();
if (name === 'webgl') return new WebGLRenderer();
return new CanvasRenderer();
},
});
});
return [];
};
return graph;
};

View File

@ -19,6 +19,7 @@ export { behaviorLassoSelect } from './behavior-lasso-select';
export { behaviorOptimizeViewportTransform } from './behavior-optimize-viewport-transform';
export { behaviorScrollCanvas } from './behavior-scroll-canvas';
export { behaviorZoomCanvas } from './behavior-zoom-canvas';
export { canvasSwitchRenderer } from './canvas-switch-renderer';
export { caseIndentedTree } from './case-indented-tree';
export { caseOrgChart } from './case-org-chart';
export { elementCombo } from './combo';

View File

@ -1,4 +1,3 @@
import { parsePoint } from '@/src/utils/point';
import { createGraphCanvas } from '@@/utils';
describe('Canvas', () => {
@ -19,32 +18,32 @@ describe('Canvas', () => {
it('coordinate transform', () => {
// TODO g canvas client 坐标转换疑似异常
expect(parsePoint(svg.viewport2Client({ x: 0, y: 0 }))).toBeCloseTo([0, 0, 0]);
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([0, 0, 0]);
expect(svg.getClientByCanvas([0, 0])).toBeCloseTo([0, 0, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([0, 0, 0]);
expect(parsePoint(svg.client2Viewport({ x: 0, y: 0 }))).toBeCloseTo([0, 0, 0]);
expect(parsePoint(svg.canvas2Viewport({ x: 0, y: 0 }))).toBeCloseTo([0, 0, 0]);
expect(svg.getViewportByClient([0, 0])).toBeCloseTo([0, 0, 0]);
expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([0, 0, 0]);
const camera = svg.getCamera();
camera.pan(100, 100);
expect([...camera.getPosition()]).toBeCloseTo([350, 350, 500]);
expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]);
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([100, 100, 0]);
expect(parsePoint(svg.canvas2Viewport({ x: 0, y: 0 }))).toBeCloseTo([-100, -100, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([100, 100, 0]);
expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([-100, -100, 0]);
// camera pan 采用相对移动
camera.pan(-200, -200);
// focal point wont change
// expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]);
expect([...camera.getPosition()]).toBeCloseTo([150, 150, 500]);
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([-100, -100, 0]);
expect(parsePoint(svg.canvas2Viewport({ x: 0, y: 0 }))).toBeCloseTo([100, 100, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]);
expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([100, 100, 0]);
// move to origin
camera.pan(100, 100);
camera.pan(-100, -100);
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([-100, -100, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]);
camera.pan(100, 100);
});
@ -69,8 +68,8 @@ describe('Canvas', () => {
camera.gotoLandmark(landmark1, { onfinish: resolve });
});
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([100, 100, 0]);
expect(parsePoint(svg.canvas2Viewport({ x: 0, y: 0 }))).toBeCloseTo([-100, -100, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([100, 100, 0]);
expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([-100, -100, 0]);
const landmark2 = camera.createLandmark('landmark2', {
// 视点坐标 / viewport coordinates
@ -83,8 +82,8 @@ describe('Canvas', () => {
camera.gotoLandmark(landmark2, { onfinish: resolve });
});
expect(parsePoint(svg.viewport2Canvas({ x: 0, y: 0 }))).toBeCloseTo([-100, -100, 0]);
expect(parsePoint(svg.canvas2Viewport({ x: 0, y: 0 }))).toBeCloseTo([100, 100, 0]);
expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]);
expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([100, 100, 0]);
expect([...camera.getFocalPoint()]).toBeCloseTo([150, 150, 0]);
expect([...camera.getPosition()]).toBeCloseTo([150, 150, 500]);

View File

@ -1,5 +1,6 @@
import { GraphEvent } from '@/src';
import { createGraph } from '@@/utils';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
describe('event', () => {
it('canvas ready', async () => {
@ -153,4 +154,33 @@ describe('event', () => {
graph.destroy();
});
it('renderer change event', async () => {
const graph = createGraph({
data: {
nodes: [{ id: 'node-1' }, { id: 'node-2' }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
},
});
const beforeRendererChange = jest.fn();
const afterRendererChange = jest.fn();
graph.on(GraphEvent.BEFORE_RENDERER_CHANGE, beforeRendererChange);
graph.on(GraphEvent.AFTER_RENDERER_CHANGE, afterRendererChange);
await graph.render();
expect(beforeRendererChange).toHaveBeenCalledTimes(0);
expect(afterRendererChange).toHaveBeenCalledTimes(0);
const renderer = () => new CanvasRenderer();
graph.setOptions({
renderer,
});
expect(beforeRendererChange).toHaveBeenCalledTimes(1);
expect(afterRendererChange).toHaveBeenCalledTimes(1);
});
});

View File

@ -6,6 +6,7 @@ import type { Node, Point } from '@/src/types';
import { resetEntityCounter } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Renderer as SVGRenderer } from '@antv/g-svg';
import { Renderer as WebGLRenderer } from '@antv/g-webgl';
import { OffscreenCanvasContext } from './offscreen-canvas-context';
function getRenderer(renderer: string) {
@ -13,6 +14,7 @@ function getRenderer(renderer: string) {
case 'svg':
return new SVGRenderer();
case 'webgl':
return new WebGLRenderer();
case 'canvas':
return new CanvasRenderer();
default:

View File

@ -71,6 +71,7 @@
},
"devDependencies": {
"@antv/g-svg": "^2.0.8",
"@antv/g-webgl": "^2.0.11",
"@antv/layout-gpu": "^1.1.6",
"@antv/layout-wasm": "^1.4.1",
"@types/hull.js": "^1.0.4",

View File

@ -167,4 +167,16 @@ export enum GraphEvent {
* <en/> After destruction
*/
AFTER_DESTROY = 'afterdestroy',
/**
* <zh/>
*
* <en/> Before the renderer changes
*/
BEFORE_RENDERER_CHANGE = 'beforerendererchange',
/**
* <zh/>
*
* <en/> After the renderer changes
*/
AFTER_RENDERER_CHANGE = 'afterrendererchange',
}

View File

@ -215,7 +215,7 @@ export class HTML extends BaseNode<HTMLStyleProps> {
const { x, y } = this.getViewportXY(normalizedEvent);
event.viewport.x = x;
event.viewport.y = y;
const { x: canvasX, y: canvasY } = this.attributes.context!.canvas.viewport2Canvas(event.viewport);
const [canvasX, canvasY] = this.context.canvas.getCanvasByViewport([x, y]);
event.canvas.x = canvasX;
event.canvas.y = canvasY;
event.global.copyFrom(event.canvas);

View File

@ -1,11 +1,12 @@
import type { DisplayObject, CanvasConfig as GCanvasConfig, IChildNode } from '@antv/g';
import type { Cursor, DisplayObject, CanvasConfig as GCanvasConfig, IChildNode } from '@antv/g';
import { CanvasEvent, Canvas as GCanvas } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Plugin as DragNDropPlugin } from '@antv/g-plugin-dragndrop';
import { createDOM } from '@antv/util';
import type { CanvasOptions } from '../spec/canvas';
import type { CanvasLayer } from '../types';
import type { CanvasLayer, Point } from '../types';
import { getBBoxSize, getCombinedBBox } from '../utils/bbox';
import { parsePoint, toPointObject } from '../utils/point';
export interface CanvasConfig
extends Pick<GCanvasConfig, 'container' | 'devicePixelRatio' | 'width' | 'height' | 'cursor' | 'background'> {
@ -40,7 +41,7 @@ export interface DataURLOptions {
const layersName: CanvasLayer[] = ['background', 'main', 'label', 'transient'];
export class Canvas extends GCanvas {
export class Canvas {
private extends: {
config: CanvasConfig;
renderer: CanvasOptions['renderer'];
@ -48,7 +49,13 @@ export class Canvas extends GCanvas {
layers: Record<CanvasLayer, GCanvas>;
};
public getLayer(layer: CanvasLayer) {
private config: CanvasConfig;
public getConfig() {
return this.config;
}
public getLayer(layer: CanvasLayer = 'main') {
return this.extends.layers[layer];
}
@ -73,17 +80,46 @@ export class Canvas extends GCanvas {
return this.extends.renderers[layer];
}
/**
* <zh/>
*
* <en/> Get camera
* @param layer - <zh/> <en/> Layer
* @returns <zh/> <en/> Camera
*/
public getCamera(layer: CanvasLayer = 'main') {
return this.getLayer(layer).getCamera();
}
public getRoot(layer: CanvasLayer = 'main') {
return this.getLayer(layer).getRoot();
}
public getContextService(layer: CanvasLayer = 'main') {
return this.getLayer(layer).getContextService();
}
public setCursor(cursor: Cursor): void {
this.config.cursor = cursor;
this.getLayer().setCursor(cursor);
}
public get document() {
return this.getLayer().document;
}
public get context() {
return this.getLayer().context;
}
constructor(config: CanvasConfig) {
this.config = config;
const { renderer, background, cursor, ...restConfig } = config;
const renderers = createRenderers(renderer);
// init main canvas
super({ ...restConfig, supportsMutipleCanvasesInOneContainer: true, cursor, renderer: renderers.main });
const layers = Object.fromEntries(
layersName.map((layer) => {
if (layer === 'main') return [layer, this];
const canvas = new GCanvas({
...restConfig,
supportsMutipleCanvasesInOneContainer: true,
@ -106,20 +142,12 @@ export class Canvas extends GCanvas {
}
public get ready() {
return Promise.all([
super.ready,
...Object.entries(this.getLayers()).map(([layer, canvas]) =>
layer === 'main' ? Promise.resolve() : canvas.ready,
),
]);
return Promise.all(Object.entries(this.getLayers()).map(([, canvas]) => canvas.ready));
}
public resize(width: number, height: number) {
Object.assign(this.extends.config, { width, height });
Object.values(this.getLayers()).forEach((canvas) => {
if (canvas === this) super.resize(width, height);
else canvas.resize(width, height);
});
Object.values(this.getLayers()).forEach((canvas) => canvas.resize(width, height));
}
public getBounds() {
@ -142,19 +170,41 @@ export class Canvas extends GCanvas {
public appendChild<T extends IChildNode>(child: T, index?: number): T {
const layer = ((child as unknown as DisplayObject).style?.$layer || 'main') as CanvasLayer;
if (layer === 'main') return super.appendChild(child, index);
return this.getLayer(layer).appendChild(child, index);
}
public setRenderer(renderer: any) {
public setRenderer(renderer: CanvasOptions['renderer']) {
if (renderer === this.extends.renderer) return;
const renderers = createRenderers(renderer);
this.extends.renderers = renderers;
Object.entries(renderers).forEach(([layer, instance]) => this.getLayer(layer as CanvasLayer).setRenderer(instance));
configCanvasDom(this.getLayers());
}
Object.entries(renderers).forEach(([layer, instance]) => {
if (layer === 'main') super.setRenderer(instance);
else this.getLayer(layer as CanvasLayer).setRenderer(instance);
});
public getCanvasByViewport(point: Point): Point {
return parsePoint(this.getLayer().viewport2Canvas(toPointObject(point)));
}
public getViewportByCanvas(point: Point): Point {
return parsePoint(this.getLayer().canvas2Viewport(toPointObject(point)));
}
public getViewportByClient(point: Point): Point {
return parsePoint(this.getLayer().client2Viewport(toPointObject(point)));
}
public getClientByViewport(point: Point): Point {
return parsePoint(this.getLayer().viewport2Client(toPointObject(point)));
}
public getClientByCanvas(point: Point): Point {
return this.getClientByViewport(this.getViewportByCanvas(point));
}
public getCanvasByClient(point: Point): Point {
const main = this.getLayer();
const viewportPoint = main.client2Viewport(toPointObject(point));
return parsePoint(main.viewport2Canvas(viewportPoint));
}
public async toDataURL(options: Partial<DataURLOptions> = {}) {
@ -190,10 +240,10 @@ export class Canvas extends GCanvas {
// Handle label canvas
const label = this.getLayer('label').getRoot().cloneNode(true);
const originCanvasPosition = offscreenCanvas.viewport2Canvas({ x: 0, y: 0 });
const currentCanvasPosition = this.viewport2Canvas({ x: 0, y: 0 });
const currentCanvasPosition = this.getCanvasByViewport([0, 0]);
label.translate([
currentCanvasPosition.x - originCanvasPosition.x,
currentCanvasPosition.y - originCanvasPosition.y,
currentCanvasPosition[0] - originCanvasPosition.x,
currentCanvasPosition[1] - originCanvasPosition.y,
]);
label.scale(1 / this.getCamera().getZoom());
offscreenCanvas.appendChild(label);
@ -226,12 +276,11 @@ export class Canvas extends GCanvas {
});
}
public destroy(cleanUp?: boolean, skipTriggerEvent?: boolean) {
public destroy() {
Object.values(this.getLayers()).forEach((canvas) => {
const camera = canvas.getCamera();
camera.cancelLandmarkAnimation();
if (canvas === this) super.destroy(cleanUp, skipTriggerEvent);
else canvas.destroy(cleanUp, skipTriggerEvent);
canvas.destroy();
});
}
}

View File

@ -451,6 +451,9 @@ export class ElementController {
},
{
before: () => {
// 通过 elementMap[id] 访问最新的 element防止 type 不同导致的 element 丢失
// Access the latest element through elementMap[id] to prevent the loss of element caused by different types
const element = this.elementMap[id];
if (stage !== 'collapse') updateStyle(element, style);
if (stage === 'visibility') {
@ -462,6 +465,7 @@ export class ElementController {
}
},
after: () => {
const element = this.elementMap[id];
if (stage === 'collapse') updateStyle(element, style);
if (exactStage === 'hide') updateStyle(element, { visibility: getCachedStyle(element, 'visibility') });
this.emit(new ElementLifeCycleEvent(GraphEvent.AFTER_ELEMENT_UPDATE, elementType, datum), context);

View File

@ -43,7 +43,6 @@ import { isCollapsed } from '../utils/collapsibility';
import { sizeOf } from '../utils/dom';
import { GraphLifeCycleEvent, emit } from '../utils/event';
import { idOf } from '../utils/id';
import { parsePoint, toPointObject } from '../utils/point';
import { format } from '../utils/print';
import { subtract } from '../utils/vector';
import { Animation } from './animation';
@ -120,6 +119,15 @@ export class Graph extends EventEmitter {
public setOptions(options: GraphOptions): void {
const { behaviors, combo, data, edge, height, layout, node, plugins, theme, transforms, width, renderer } = options;
if (renderer) {
const canvas = this.context.canvas;
if (canvas) {
this.emit(GraphEvent.BEFORE_RENDERER_CHANGE, { renderer: this.options.renderer });
canvas.setRenderer(renderer);
this.emit(GraphEvent.AFTER_RENDERER_CHANGE, { renderer });
}
}
Object.assign(this.options, options);
if (behaviors) this.setBehaviors(behaviors);
@ -133,7 +141,6 @@ export class Graph extends EventEmitter {
if (transforms) this.setTransforms(transforms);
if (isNumber(width) || isNumber(height))
this.setSize(width ?? this.options.width ?? 0, height ?? this.options.height ?? 0);
if (renderer) this.context.canvas?.setRenderer(renderer);
}
/**
@ -1785,7 +1792,7 @@ export class Graph extends EventEmitter {
* @apiCategory viewport
*/
public getCanvasByViewport(point: Point): Point {
return parsePoint(this.context.canvas!.viewport2Canvas(toPointObject(point)));
return this.context.canvas.getCanvasByViewport(point);
}
/**
@ -1797,7 +1804,7 @@ export class Graph extends EventEmitter {
* @apiCategory viewport
*/
public getViewportByCanvas(point: Point): Point {
return parsePoint(this.context.canvas.canvas2Viewport(toPointObject(point)));
return this.context.canvas.getViewportByCanvas(point);
}
/**
@ -1809,8 +1816,7 @@ export class Graph extends EventEmitter {
* @apiCategory viewport
*/
public getClientByCanvas(point: Point): Point {
const viewportPoint = this.context.canvas.canvas2Viewport(toPointObject(point));
return parsePoint(this.context.canvas.viewport2Canvas(viewportPoint));
return this.context.canvas.getClientByCanvas(point);
}
/**
@ -1822,8 +1828,7 @@ export class Graph extends EventEmitter {
* @apiCategory viewport
*/
public getCanvasByClient(point: Point): Point {
const viewportPoint = this.context.canvas.client2Viewport(toPointObject(point));
return parsePoint(this.context.canvas.viewport2Canvas(viewportPoint));
return this.context.canvas.getCanvasByClient(point);
}
/**