diff --git a/packages/g6/src/runtime/controller/index.ts b/packages/g6/src/runtime/controller/index.ts index a48472f9ee..525a8a293f 100644 --- a/packages/g6/src/runtime/controller/index.ts +++ b/packages/g6/src/runtime/controller/index.ts @@ -2,5 +2,6 @@ export * from './data'; export * from './interaction'; export * from './item'; export * from './layout'; +export * from './viewport'; export * from './theme'; export * from './extension'; diff --git a/packages/g6/src/runtime/controller/item.ts b/packages/g6/src/runtime/controller/item.ts index 1c30438027..b9a1aa97d1 100644 --- a/packages/g6/src/runtime/controller/item.ts +++ b/packages/g6/src/runtime/controller/item.ts @@ -1,18 +1,17 @@ +import { AABB, Canvas, DisplayObject, Group } from '@antv/g'; import { GraphChange, ID } from '@antv/graphlib'; -import { Group, AABB, DisplayObject, Canvas } from '@antv/g'; -import { ComboModel, IGraph } from '../../types'; -import registry from '../../stdlib'; -import { getExtension } from '../../util/extension'; -import { GraphCore } from '../../types/data'; -import { NodeDisplayModel, NodeEncode, NodeModel, NodeModelData } from '../../types/node'; -import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeModelData } from '../../types/edge'; -import Node from '../../item/node'; -import Edge from '../../item/edge'; -import Combo from '../../item/combo'; -import { ThemeSpecification, ItemThemeSpecifications, ItemStyleSet } from '../../types/theme'; import { isArray, isObject } from '@antv/util'; -import { ITEM_TYPE, SHAPE_TYPE, ShapeStyle } from '../../types/item'; +import Combo from '../../item/combo'; +import Edge from '../../item/edge'; +import Node from '../../item/node'; +import registry from '../../stdlib'; +import { ComboModel, IGraph } from '../../types'; import { ComboDisplayModel, ComboEncode } from '../../types/combo'; +import { GraphCore } from '../../types/data'; +import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeModelData } from '../../types/edge'; +import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../../types/item'; +import { ItemStyleSet, ItemThemeSpecifications, ThemeSpecification } from '../../types/theme'; +import { getExtension } from '../../util/extension'; import { upsertShape } from '../../util/shape'; /** @@ -61,7 +60,7 @@ export class ItemController { constructor(graph: IGraph) { this.graph = graph; // get mapper for node / edge / combo - const { node, edge, combo, nodeState, edgeState, comboState } = graph.getSpecification(); + const { node, edge, combo, nodeState, edgeState, comboState } = graph.getSpecification(); this.nodeMapper = node; this.edgeMapper = edge; this.comboMapper = combo; @@ -114,7 +113,7 @@ export class ItemController { * Listener of runtime's render hook. * @param param contains inner data stored in graphCore structure */ - private onRender(param: { graphCore: GraphCore, theme: ThemeSpecification }) { + private onRender(param: { graphCore: GraphCore; theme: ThemeSpecification }) { const { graphCore, theme = {} } = param; const { graph } = this; // TODO: 0. clear groups on canvas, and create new groups @@ -177,11 +176,17 @@ export class ItemController { // === 3. add nodes === if (groupedChanges.NodeAdded.length) { - this.renderNodes(groupedChanges.NodeAdded.map((change) => change.value), nodeTheme); + this.renderNodes( + groupedChanges.NodeAdded.map((change) => change.value), + nodeTheme, + ); } // === 4. add edges === if (groupedChanges.EdgeAdded.length) { - this.renderEdges(groupedChanges.EdgeAdded.map((change) => change.value), edgeTheme); + this.renderEdges( + groupedChanges.EdgeAdded.map((change) => change.value), + edgeTheme, + ); } // === 5. update nodes's data === @@ -209,7 +214,12 @@ export class ItemController { // update the theme if the dataType value is changed let themeStyles; if (previous[nodeDataTypeField] !== current[nodeDataTypeField]) { - themeStyles = getThemeStyles(this.nodeDataTypeSet, nodeDataTypeField, current[nodeDataTypeField], nodeTheme); + themeStyles = getThemeStyles( + this.nodeDataTypeSet, + nodeDataTypeField, + current[nodeDataTypeField], + nodeTheme, + ); } const item = itemMap[id]; const innerModel = graphCore.getNode(id); @@ -247,7 +257,12 @@ export class ItemController { // update the theme if the dataType value is changed let themeStyles; if (previous[edgeDataTypeField] !== current[edgeDataTypeField]) { - themeStyles = getThemeStyles(this.edgeDataTypeSet, edgeDataTypeField, current[edgeDataTypeField], edgeTheme); + themeStyles = getThemeStyles( + this.edgeDataTypeSet, + edgeDataTypeField, + current[edgeDataTypeField], + edgeTheme, + ); } const item = itemMap[id]; const innerModel = graphCore.getEdge(id); @@ -283,9 +298,9 @@ export class ItemController { * value: state value * } */ - private onItemStateChange(param: { ids: ID[], states: string[], value: boolean }) { + private onItemStateChange(param: { ids: ID[]; states: string[]; value: boolean }) { const { ids, states, value } = param; - ids.forEach(id => { + ids.forEach((id) => { const item = this.itemMap[id]; if (!item) { console.warn(`Fail to set state for item ${id}, which is not exist.`); @@ -295,20 +310,20 @@ export class ItemController { // clear all the states item.clearStates(states); } else { - states.forEach(state => item.setState(state, value)); + states.forEach((state) => item.setState(state, value)); } }); } private onTransientUpdate(param: { - type: ITEM_TYPE | SHAPE_TYPE, - id: ID, + type: ITEM_TYPE | SHAPE_TYPE; + id: ID; config: { - style: ShapeStyle, - action: 'remove' | 'add' | 'update' | undefined, - [shapeConfig: string]: unknown, - }, - canvas: Canvas + style: ShapeStyle; + action: 'remove' | 'add' | 'update' | undefined; + [shapeConfig: string]: unknown; + }; + canvas: Canvas; }) { const { transientMap } = this; const { type, id, config = {}, canvas } = param; @@ -345,7 +360,7 @@ export class ItemController { let dataType; if (dataTypeField) dataType = node.data[dataTypeField] as string; const themeStyle = getThemeStyles(nodeDataTypeSet, dataTypeField, dataType, nodeTheme); - + this.itemMap[node.id] = new Node({ model: node, renderExtensions: nodeExtensions, @@ -405,7 +420,7 @@ export class ItemController { */ public findIdByState(itemType: ITEM_TYPE, state: string, value: string | boolean = true) { const ids = []; - Object.values(this.itemMap).forEach(item => { + Object.values(this.itemMap).forEach((item) => { if (item.getType() !== itemType) return; if (item.hasState(state) === value) ids.push(item.getID()); }); @@ -427,6 +442,10 @@ export class ItemController { return item.hasState(state); } + public getItemById(id: ID) { + return this.itemMap[id]; + } + public getItemBBox(id: ID, isKeyShape: boolean = false): AABB | false { const item = this.itemMap[id]; if (!item) { @@ -446,7 +465,12 @@ export class ItemController { } } -const getThemeStyles = (dataTypeSet: Set, dataTypeField: string, dataType: string, itemTheme: ItemThemeSpecifications): ItemStyleSet => { +const getThemeStyles = ( + dataTypeSet: Set, + dataTypeField: string, + dataType: string, + itemTheme: ItemThemeSpecifications, +): ItemStyleSet => { const { styles: themeStyles } = itemTheme; if (!dataTypeField) { // dataType field is not assigned @@ -462,4 +486,4 @@ const getThemeStyles = (dataTypeSet: Set, dataTypeField: string, dataTyp themeStyle = themeStyles[dataType] || themeStyles.others; } return themeStyle; -} \ No newline at end of file +}; diff --git a/packages/g6/src/runtime/controller/viewport.ts b/packages/g6/src/runtime/controller/viewport.ts new file mode 100644 index 0000000000..29b2e14890 --- /dev/null +++ b/packages/g6/src/runtime/controller/viewport.ts @@ -0,0 +1,92 @@ +import { ICamera, PointLike } from '@antv/g'; +import { IGraph } from '../../types'; +import { CameraAnimationOptions } from '../../types/animate'; +import { ViewportChangeHookParams } from '../../types/hook'; + +let landmarkCounter = 0; + +export class ViewportController { + public graph: IGraph; + + constructor(graph: IGraph) { + this.graph = graph; + this.tap(); + } + + /** + * Subscribe the lifecycle of graph. + */ + private tap() { + this.graph.hooks.viewportchange.tap(this.onViewportChange.bind(this)); + } + + private async onViewportChange({ transform, effectTiming }: ViewportChangeHookParams) { + const camera = this.graph.canvas.getCamera(); + const { translate, rotate, zoom, origin = this.graph.getViewportCenter() } = transform; + const currentZoom = camera.getZoom(); + + if (effectTiming) { + const { duration = 1000, easing = 'linear', easingFunction } = effectTiming; + const landmarkOptions: Partial<{ + position: [number, number] | [number, number, number] | Float32Array; + focalPoint: [number, number] | [number, number, number] | Float32Array; + zoom: number; + roll: number; + }> = {}; + + if (translate) { + const { dx = 0, dy = 0 } = translate; + const [px, py] = camera.getPosition(); + const [fx, fy] = camera.getFocalPoint(); + landmarkOptions.position = [px - dx, py - dy]; + landmarkOptions.focalPoint = [fx - dx, fy - dy]; + } + + if (zoom) { + const { ratio } = zoom; + landmarkOptions.zoom = currentZoom * ratio; + } + + if (rotate) { + const { angle } = rotate; + landmarkOptions.roll = camera.getRoll() + angle; + } + + const landmark = camera.createLandmark(`mark${landmarkCounter++}`, landmarkOptions); + return new Promise((resolve) => { + camera.gotoLandmark(landmark, { + duration: Number(duration), + easing, + easingFunction, + onfinish: () => { + resolve(undefined); + }, + }); + }); + } else { + if (translate) { + const { dx = 0, dy = 0 } = translate; + camera.pan(-dx / currentZoom, -dy / currentZoom); + } + + if (rotate) { + const { angle } = rotate; + const [x, y] = camera.getPosition(); + if (origin) { + camera.setPosition(origin.x, origin.y); + } + camera.rotate(0, 0, angle); + if (origin) { + camera.pan(x - origin.x, y - origin.y); + } + } + + if (zoom) { + const { ratio } = zoom; + camera.setZoomByViewportPoint(currentZoom * ratio, [origin.x, origin.y]); + } + } + } + + destroy() {} +} diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 3b7176d71d..3b21d57a0c 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -1,5 +1,5 @@ import EventEmitter from '@antv/event-emitter'; -import { Canvas, runtime, AABB, DisplayObject } from '@antv/g'; +import { AABB, Canvas, DisplayObject, PointLike, runtime } from '@antv/g'; import { GraphChange, ID } from '@antv/graphlib'; import { isArray, isNil, isNumber, isObject, isString } from '@antv/util'; import { @@ -10,13 +10,13 @@ import { NodeUserModel, Specification, } from '../types'; -import { AnimateCfg } from '../types/animate'; +import { CameraAnimationOptions } from '../types/animate'; import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from '../types/behavior'; import { ComboModel } from '../types/combo'; -import { Padding, Point } from '../types/common'; +import { Padding } from '../types/common'; import { GraphCore } from '../types/data'; import { EdgeModel, EdgeModelData } from '../types/edge'; -import { Hooks } from '../types/hook'; +import { Hooks, ViewportChangeHookParams } from '../types/hook'; import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../types/item'; import { ImmediatelyInvokedLayoutOptions, @@ -25,8 +25,9 @@ import { } from '../types/layout'; import { NodeModel, NodeModelData } from '../types/node'; import { ThemeRegistry, ThemeSpecification } from '../types/theme'; -import { FitViewRules, GraphAlignment } from '../types/view'; +import { FitViewRules, GraphTransformOptions } from '../types/view'; import { createCanvas } from '../util/canvas'; +import { formatPadding } from '../util/shape'; import { DataController, ExtensionController, @@ -34,6 +35,7 @@ import { ItemController, LayoutController, ThemeController, + ViewportController, } from './controller'; import Hook from './hooks'; @@ -42,7 +44,10 @@ import Hook from './hooks'; */ runtime.enableCSSParsing = false; -export default class Graph extends EventEmitter implements IGraph { +export default class Graph + extends EventEmitter + implements IGraph +{ public hooks: Hooks; // for nodes and edges, which will be separate into groups public canvas: Canvas; @@ -58,6 +63,7 @@ export default class Graph private dataController: DataController; private interactionController: InteractionController; private layoutController: LayoutController; + private viewportController: ViewportController; private itemController: ItemController; private extensionController: ExtensionController; private themeController: ThemeController; @@ -65,9 +71,9 @@ export default class Graph private defaultSpecification = { theme: { type: 'spec', - base: 'light' - } - } + base: 'light', + }, + }; constructor(spec: Specification) { super(); @@ -82,8 +88,8 @@ export default class Graph canvases: { background: this.backgroundCanvas, main: this.canvas, - transient: this.transientCanvas - } + transient: this.transientCanvas, + }, }); const { data } = spec; @@ -102,6 +108,7 @@ export default class Graph this.layoutController = new LayoutController(this); this.themeController = new ThemeController(this); this.itemController = new ItemController(this); + this.viewportController = new ViewportController(this); this.extensionController = new ExtensionController(this); } @@ -132,10 +139,10 @@ export default class Graph this.hooks = { init: new Hook<{ canvases: { - background: Canvas, - main: Canvas, - transient: Canvas - } + background: Canvas; + main: Canvas; + transient: Canvas; + }; }>({ name: 'init' }), datachange: new Hook<{ data: GraphData; type: 'replace' }>({ name: 'datachange' }), itemchange: new Hook<{ @@ -144,16 +151,24 @@ export default class Graph graphCore: GraphCore; theme: ThemeSpecification; }>({ name: 'itemchange' }), - render: new Hook<{ graphCore: GraphCore, theme: ThemeSpecification; }>({ name: 'render' }), + render: new Hook<{ graphCore: GraphCore; theme: ThemeSpecification }>({ name: 'render' }), layout: new Hook<{ graphCore: GraphCore }>({ name: 'layout' }), + viewportchange: new Hook({ name: 'viewport' }), modechange: new Hook<{ mode: string }>({ name: 'modechange' }), behaviorchange: new Hook<{ action: 'update' | 'add' | 'remove'; modes: string[]; behaviors: BehaviorOptionsOf<{}>[]; }>({ name: 'behaviorchange' }), - itemstatechange: new Hook<{ ids: ID[], state: string, value: boolean }>({ name: 'itemstatechange' }), - transientupdate: new Hook<{ type: ITEM_TYPE | SHAPE_TYPE, id: ID, config: { style: ShapeStyle, action: 'remove' | 'add' | 'update' | undefined }, canvas: Canvas }>({ name: 'transientupdate'}), // TODO + itemstatechange: new Hook<{ ids: ID[]; state: string; value: boolean }>({ + name: 'itemstatechange', + }), + transientupdate: new Hook<{ + type: ITEM_TYPE | SHAPE_TYPE; + id: ID; + config: { style: ShapeStyle; action: 'remove' | 'add' | 'update' | undefined }; + canvas: Canvas; + }>({ name: 'transientupdate' }), // TODO }; } @@ -184,7 +199,7 @@ export default class Graph const emitRender = async () => { this.hooks.render.emit({ graphCore: this.dataController.graphCore, - theme: this.themeController.specification + theme: this.themeController.specification, }); this.emit('afterrender'); @@ -211,7 +226,7 @@ export default class Graph this.hooks.datachange.emit({ data, type }); this.hooks.render.emit({ graphCore: this.dataController.graphCore, - theme: this.themeController.specification + theme: this.themeController.specification, }); this.emit('afterrender'); @@ -226,84 +241,223 @@ export default class Graph // TODO } - /** - * Move the graph with a relative vector. - * @param dx x of the relative vector - * @param dy y of the relative vector - * @param animateCfg animation configurations - * @returns - * @group View - */ - public move(dx: number, dy: number, animateCfg?: AnimateCfg) { - // TODO + public getViewportCenter(): PointLike { + const { width, height } = this.canvas.getConfig(); + return { x: width! / 2, y: height! / 2 }; + } + public async transform( + options: GraphTransformOptions, + effectTiming?: CameraAnimationOptions, + ): Promise { + await this.hooks.viewportchange.emitLinearAsync({ + transform: options, + effectTiming, + }); + this.emit('viewportchange', options); } /** - * Move the graph and align to a point. - * @param x position on the canvas to align - * @param y position on the canvas to align - * @param alignment alignment of the graph content - * @param animateCfg animation configurations - * @returns - * @group View + * Stop the current transition of transform immediately. */ - public moveTo(x: number, y: number, alignment: GraphAlignment, animateCfg?: AnimateCfg) { - // TODO + public stopTransformTransition() { + this.canvas.getCamera().cancelLandmarkAnimation(); + } + + /** + * Move the graph with a relative distance under viewport coordinates. + * @param dx x of the relative distance + * @param dy y of the relative distance + * @param effectTiming animation configurations + */ + public async translate(dx: number, dy: number, effectTiming?: CameraAnimationOptions) { + await this.transform( + { + translate: { + dx, + dy, + }, + }, + effectTiming, + ); + } + + /** + * Move the graph to destination under viewport coordinates. + * @param destination destination under viewport coordinates. + * @param effectTiming animation configurations + */ + public async translateTo({ x, y }: PointLike, effectTiming?: CameraAnimationOptions) { + const { x: cx, y: cy } = this.getViewportCenter(); + await this.translate(cx - x, cy - y, effectTiming); } /** * Zoom the graph with a relative ratio. * @param ratio relative ratio to zoom - * @param center zoom center - * @param animateCfg animation configurations - * @returns - * @group View + * @param origin origin under viewport coordinates. + * @param effectTiming animation configurations */ - public zoom(ratio: number, center?: Point, animateCfg?: AnimateCfg) { - // TODO + public async zoom(ratio: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) { + await this.transform( + { + zoom: { + ratio, + }, + origin, + }, + effectTiming, + ); } /** * Zoom the graph to a specified ratio. - * @param toRatio specified ratio - * @param center zoom center - * @param animateCfg animation configurations - * @returns - * @group View + * @param zoom specified ratio + * @param origin zoom center + * @param effectTiming animation configurations */ - public zoomTo(toRatio: number, center?: Point, animateCfg?: AnimateCfg) { - // TODO + public async zoomTo(zoom: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) { + await this.zoom(zoom / this.canvas.getCamera().getZoom(), origin, effectTiming); + } + + /** + * Return the current zoom level of camera. + * @returns current zoom + */ + public getZoom() { + return this.canvas.getCamera().getZoom(); + } + + /** + * Rotate the graph with a relative angle. + * @param angle + * @param origin + * @param effectTiming + */ + public async rotate(angle: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) { + await this.transform( + { + rotate: { + angle, + }, + origin, + }, + effectTiming, + ); + } + + /** + * Rotate the graph to an absolute angle. + * @param angle + * @param origin + * @param effectTiming + */ + public async rotateTo(angle: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) { + await this.rotate(angle - this.canvas.getCamera().getRoll(), origin, effectTiming); } /** * Fit the graph content to the view. - * @param padding padding while fitting - * @param rules rules for fitting - * @param animateCfg animation configurations - * @returns - * @group View + * @param options.padding padding while fitting + * @param options.rules rules for fitting + * @param effectTiming animation configurations */ - public fitView(padding?: Padding, rules?: FitViewRules, animateCfg?: AnimateCfg) { - // TODO + public async fitView( + options?: { + padding: Padding; + rules: FitViewRules; + }, + effectTiming?: CameraAnimationOptions, + ) { + const { padding, rules } = options || {}; + const [top, right, bottom, left] = padding ? formatPadding(padding) : [0, 0, 0, 0]; + const { direction = 'both', ratioRule = 'min' } = rules || {}; + + // Get the bounds of the whole graph. + const { + center: [graphCenterX, graphCenterY], + halfExtents, + } = this.canvas.document.documentElement.getBounds(); + const origin = this.canvas.canvas2Viewport({ x: graphCenterX, y: graphCenterY }); + const { width: viewportWidth, height: viewportHeight } = this.canvas.getConfig(); + + const graphWidth = halfExtents[0] * 2; + const graphHeight = halfExtents[1] * 2; + const tlInCanvas = this.canvas.viewport2Canvas({ x: left, y: top }); + const brInCanvas = this.canvas.viewport2Canvas({ + x: viewportWidth! - right, + y: viewportHeight! - bottom, + }); + + const targetViewWidth = brInCanvas.x - tlInCanvas.x; + const targetViewHeight = brInCanvas.y - tlInCanvas.y; + + const wRatio = targetViewWidth / graphWidth; + const hRatio = targetViewHeight / graphHeight; + + let ratio: number; + if (direction === 'x') { + ratio = wRatio; + } else if (direction === 'y') { + ratio = hRatio; + } else { + ratio = ratioRule === 'max' ? Math.max(wRatio, hRatio) : Math.min(wRatio, hRatio); + } + + await this.transform( + { + translate: { + dx: viewportWidth! / 2 - origin.x, + dy: viewportHeight! / 2 - origin.y, + }, + zoom: { + ratio, + }, + }, + effectTiming, + ); } /** * Fit the graph center to the view center. - * @param animateCfg animation configurations - * @returns - * @group View + * @param effectTiming animation configurations */ - public fitCenter(animateCfg?: AnimateCfg) { - // TODO + public async fitCenter(effectTiming?: CameraAnimationOptions) { + // Get the bounds of the whole graph. + const { + center: [graphCenterX, graphCenterY], + } = this.canvas.document.documentElement.getBounds(); + await this.translateTo( + this.canvas.canvas2Viewport({ x: graphCenterX, y: graphCenterY }), + effectTiming, + ); } /** * Move the graph to make the item align the view center. * @param item node/edge/combo item or its id - * @param animateCfg animation configurations - * @returns - * @group View + * @param effectTiming animation configurations */ - public focusItem(ids: ID | ID[], animateCfg?: AnimateCfg) { - // TODO + public async focusItem(id: ID | ID[], effectTiming?: CameraAnimationOptions) { + let bounds: AABB | null = null; + for (const itemId of !isArray(id) ? [id] : id) { + const item = this.itemController.getItemById(itemId); + if (item) { + const itemBounds = item.group.getBounds(); + if (!bounds) { + bounds = itemBounds; + } else { + bounds.add(itemBounds); + } + } + } + + if (bounds) { + const { + center: [itemCenterX, itemCenterY], + } = bounds; + await this.translateTo( + this.canvas.canvas2Viewport({ x: itemCenterX, y: itemCenterY }), + effectTiming, + ); + } } // ===== item operations ===== @@ -371,7 +525,7 @@ export default class Graph public getRelatedEdgesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): EdgeModel[] { return this.dataController.findRelatedEdgeIds(nodeId, direction); } - /** + /** * Get one-hop node ids from a start node. * @param nodeId id of the start node * @returns one-hop node ids @@ -380,7 +534,7 @@ export default class Graph public getNeighborNodesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): NodeModel[] { return this.dataController.findNeighborNodeIds(nodeId, direction); } - + /** * Find items which has the state. * @param itemType item type @@ -430,7 +584,7 @@ export default class Graph type: itemType, changes: graphCore.reduceChanges(event.changes), graphCore, - theme: specification + theme: specification, }); }); @@ -466,7 +620,7 @@ export default class Graph type: itemType, changes: event.changes, graphCore, - theme: specification + theme: specification, }); }); this.hooks.datachange.emit({ @@ -505,7 +659,7 @@ export default class Graph type: itemType, changes: event.changes, graphCore, - theme: specification + theme: specification, }); }); @@ -587,7 +741,7 @@ export default class Graph * @returns rendering bounding box. returns false if the item is not exist * @group Item */ - public getRenderBBox(id: ID | undefined): AABB | false{ + public getRenderBBox(id: ID | undefined): AABB | false { if (!id) return this.canvas.getRoot().getRenderBounds(); return this.itemController.getItemBBox(id); } @@ -769,7 +923,11 @@ export default class Graph * @returns upserted shape or group * @group Interaction */ - public drawTransient(type: ITEM_TYPE | SHAPE_TYPE, id: ID, config: { action: 'remove' | 'add' | 'update' | undefined, style: ShapeStyle}): DisplayObject { + public drawTransient( + type: ITEM_TYPE | SHAPE_TYPE, + id: ID, + config: { action: 'remove' | 'add' | 'update' | undefined; style: ShapeStyle }, + ): DisplayObject { this.hooks.transientupdate.emit({ type, id, config, canvas: this.transientCanvas }); return this.itemController.getTransient(String(id)); } diff --git a/packages/g6/src/types/animate.ts b/packages/g6/src/types/animate.ts index 56bcc26238..31eef8962c 100644 --- a/packages/g6/src/types/animate.ts +++ b/packages/g6/src/types/animate.ts @@ -1,3 +1,5 @@ +import { IAnimationEffectTiming } from '@antv/g'; + export interface AnimateCfg { /** * Whether enable animation. @@ -39,7 +41,7 @@ export interface AnimateCfg { * @type {function} */ resumeCallback?: () => void; -}; +} export type AnimateWhen = 'show' | 'exit' | 'update' | 'last'; @@ -47,4 +49,7 @@ export interface AnimateAttr { when: AnimateWhen; type: string; [param: string]: unknown; -} \ No newline at end of file +} + +export interface CameraAnimationOptions + extends Pick {} diff --git a/packages/g6/src/types/graph.ts b/packages/g6/src/types/graph.ts index ec0c839815..c42a8682e6 100644 --- a/packages/g6/src/types/graph.ts +++ b/packages/g6/src/types/graph.ts @@ -1,21 +1,24 @@ import EventEmitter from '@antv/event-emitter'; -import { Canvas, AABB, DisplayObject } from '@antv/g'; +import { AABB, Canvas, DisplayObject, PointLike } from '@antv/g'; import { ID } from '@antv/graphlib'; import { Hooks } from '../types/hook'; -import { AnimateCfg } from './animate'; +import { CameraAnimationOptions } from './animate'; import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from './behavior'; import { ComboModel, ComboUserModel } from './combo'; import { Padding, Point } from './common'; -import { DataChangeType, GraphData } from './data'; +import { GraphData } from './data'; import { EdgeModel, EdgeUserModel } from './edge'; import { ITEM_TYPE, SHAPE_TYPE } from './item'; import { LayoutOptions } from './layout'; import { NodeModel, NodeUserModel } from './node'; import { Specification } from './spec'; import { ThemeRegistry } from './theme'; -import { FitViewRules, GraphAlignment } from './view'; +import { FitViewRules, GraphTransformOptions } from './view'; -export interface IGraph extends EventEmitter { +export interface IGraph< + B extends BehaviorRegistry = BehaviorRegistry, + T extends ThemeRegistry = ThemeRegistry, +> extends EventEmitter { hooks: Hooks; canvas: Canvas; destroyed: boolean; @@ -182,63 +185,98 @@ export interface IGraph void; + translate: (dx: number, dy: number, effectTiming?: CameraAnimationOptions) => Promise; /** * Move the graph and align to a point. - * @param x position on the canvas to align - * @param y position on the canvas to align - * @param alignment alignment of the graph content - * @param animateCfg animation configurations - * @returns - * @group View + * @param point position on the canvas to align + * @param effectTiming animation configurations */ - moveTo: (x: number, y: number, alignment: GraphAlignment, animateCfg?: AnimateCfg) => void; + translateTo: (point: PointLike, effectTiming?: CameraAnimationOptions) => Promise; + /** + * Return the current zoom level of camera. + * @returns current zoom + */ + getZoom: () => number; /** * Zoom the graph with a relative ratio. * @param ratio relative ratio to zoom * @param center zoom center - * @param animateCfg animation configurations - * @returns - * @group View + * @param effectTiming animation configurations */ - zoom: (ratio: number, center?: Point, animateCfg?: AnimateCfg) => void; + zoom: (ratio: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise; /** * Zoom the graph to a specified ratio. * @param toRatio specified ratio * @param center zoom center - * @param animateCfg animation configurations - * @returns - * @group View + * @param effectTiming animation configurations */ - zoomTo: (toRatio: number, center?: Point, animateCfg?: AnimateCfg) => void; + zoomTo: (toRatio: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise; + /** + * Rotate the graph with a relative angle in clockwise. + * @param angle + * @param center + * @param effectTiming + */ + rotate: (angle: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise; + /** + * Rotate the graph to an absolute angle in clockwise. + * @param toAngle + * @param center + * @param effectTiming + */ + rotateTo: ( + toAngle: number, + center?: Point, + effectTiming?: CameraAnimationOptions, + ) => Promise; + + /** + * Transform the graph with a CSS-Transform-like syntax. + * @param options + * @param effectTiming + */ + transform: ( + options: GraphTransformOptions, + effectTiming?: CameraAnimationOptions, + ) => Promise; + /** + * Stop the current transition of transform immediately. + */ + stopTransformTransition: () => void; + /** + * Return the center of viewport, e.g. for a 500 * 500 canvas, its center is [250, 250]. + */ + getViewportCenter: () => PointLike; /** * Fit the graph content to the view. - * @param padding padding while fitting - * @param rules rules for fitting - * @param animateCfg animation configurations + * @param options.padding padding while fitting + * @param options.rules rules for fitting + * @param effectTiming animation configurations * @returns * @group View */ - fitView: (padding?: Padding, rules?: FitViewRules, animateCfg?: AnimateCfg) => void; + fitView: ( + options?: { + padding: Padding; + rules: FitViewRules; + }, + effectTiming?: CameraAnimationOptions, + ) => Promise; /** * Fit the graph center to the view center. - * @param animateCfg animation configurations + * @param effectTiming animation configurations * @returns * @group View */ - fitCenter: (animateCfg?: AnimateCfg) => void; + fitCenter: (effectTiming?: CameraAnimationOptions) => Promise; /** * Move the graph to make the item align the view center. * @param item node/edge/combo item or its id - * @param animateCfg animation configurations - * @returns - * @group View + * @param effectTiming animation configurations */ - focusItem: (ids: ID | ID[], animateCfg?: AnimateCfg) => void; + focusItem: (id: ID | ID[], effectTiming?: CameraAnimationOptions) => Promise; // ===== item operations ===== /** diff --git a/packages/g6/src/types/hook.ts b/packages/g6/src/types/hook.ts index 224144438d..804b44a136 100644 --- a/packages/g6/src/types/hook.ts +++ b/packages/g6/src/types/hook.ts @@ -1,11 +1,13 @@ import { Canvas } from '@antv/g'; -import { DataChangeType, GraphCore, GraphData } from "./data"; -import { NodeModelData } from "./node"; -import { EdgeModelData } from "./edge"; -import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from "./item"; -import { GraphChange, ID } from "@antv/graphlib"; -import { LayoutOptions } from "./layout"; -import { ThemeSpecification } from "./theme"; +import { GraphChange, ID } from '@antv/graphlib'; +import { CameraAnimationOptions } from './animate'; +import { DataChangeType, GraphCore, GraphData } from './data'; +import { EdgeModelData } from './edge'; +import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from './item'; +import { LayoutOptions } from './layout'; +import { NodeModelData } from './node'; +import { ThemeSpecification } from './theme'; +import { GraphTransformOptions } from './view'; export interface IHook { name: string; @@ -16,18 +18,23 @@ export interface IHook { emitLinearAsync: (param: T) => Promise; } +export type ViewportChangeHookParams = { + transform: GraphTransformOptions; + effectTiming?: CameraAnimationOptions; +}; + export interface Hooks { init: IHook<{ canvases: { - background: Canvas, - main: Canvas, - transient: Canvas - } + background: Canvas; + main: Canvas; + transient: Canvas; + }; }>; // data datachange: IHook<{ type: DataChangeType; - data: GraphData + data: GraphData; }>; itemchange: IHook<{ type: ITEM_TYPE; @@ -35,7 +42,7 @@ export interface Hooks { graphCore: GraphCore; theme: ThemeSpecification; }>; - render: IHook<{ graphCore: GraphCore, theme: ThemeSpecification }>; // TODO: define param template + render: IHook<{ graphCore: GraphCore; theme: ThemeSpecification }>; // TODO: define param template layout: IHook<{ graphCore: GraphCore; options?: LayoutOptions }>; // TODO: define param template // 'updatelayout': IHook; // TODO: define param template modechange: IHook<{ mode: string }>; @@ -54,12 +61,12 @@ export interface Hooks { id: ID; canvas: Canvas; config: { - style: ShapeStyle, - action: 'remove' | 'add' | 'update' | undefined + style: ShapeStyle; + action: 'remove' | 'add' | 'update' | undefined; }; }>; // TODO: define param template - // 'viewportchange': IHook; // TODO: define param template + viewportchange: IHook; // 'destroy': IHook; // TODO: define param template // TODO: more timecycles here } diff --git a/packages/g6/src/types/view.ts b/packages/g6/src/types/view.ts index 34651dd941..97b4ef6501 100644 --- a/packages/g6/src/types/view.ts +++ b/packages/g6/src/types/view.ts @@ -4,4 +4,27 @@ export interface FitViewRules { ratioRule?: 'max' | 'min'; // Ratio rule to fit. } -export type GraphAlignment = 'left-top' | 'right-top' | 'left-bottom' | 'right-bottom' | 'center' | [number, number]; \ No newline at end of file +export type GraphAlignment = + | 'left-top' + | 'right-top' + | 'left-bottom' + | 'right-bottom' + | 'center' + | [number, number]; + +export type GraphTransformOptions = { + translate?: { + dx: number; + dy: number; + }; + rotate?: { + angle: number; + }; + zoom?: { + ratio: number; + }; + origin?: { + x: number; + y: number; + }; +}; diff --git a/packages/g6/src/util/shape.ts b/packages/g6/src/util/shape.ts index fd289021fb..c1b8335c6e 100644 --- a/packages/g6/src/util/shape.ts +++ b/packages/g6/src/util/shape.ts @@ -140,7 +140,7 @@ export const formatPadding = (value, defaultArr = DEFAULT_LABEL_BG_PADDING) => { /** * Merge multiple shape style map including undefined value in incoming map. * @param styleMaps shapes' styles map array, the latter item in the array will be merged into the former - * @returns + * @returns */ export const mergeStyles = (styleMaps: ItemShapeStyles[]) => { let currentResult = styleMaps[0]; @@ -148,29 +148,29 @@ export const mergeStyles = (styleMaps: ItemShapeStyles[]) => { if (i > 0) currentResult = merge2Styles(currentResult, styleMap); }); return currentResult; -} +}; /** * Merge two shape style map including undefined value in incoming map. * @param styleMap1 shapes' styles map as current map * @param styleMap2 shapes' styles map as incoming map - * @returns + * @returns */ const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) => { if (!styleMap1) return clone(styleMap2); else if (!styleMap2) return clone(styleMap1); const mergedStyle = clone(styleMap1); - Object.keys(styleMap2).forEach(shapeId => { + Object.keys(styleMap2).forEach((shapeId) => { const style = styleMap2[shapeId]; mergedStyle[shapeId] = mergedStyle[shapeId] || {}; if (!style) return; - Object.keys(style).forEach(styleName => { + Object.keys(style).forEach((styleName) => { const value = style[styleName]; mergedStyle[shapeId][styleName] = value; }); }); return mergedStyle; -} +}; /** * Whether two polygons are intersected. @@ -179,10 +179,10 @@ const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) => */ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): boolean => { const getBBox = (points): Partial => { - const xArr = points.map(p => p[0]); - const yArr = points.map(p => p[1]); + const xArr = points.map((p) => p[0]); + const yArr = points.map((p) => p[1]); return { - min: [Math.min.apply(null, xArr), Math.min.apply(null, yArr), 0], + min: [Math.min.apply(null, xArr), Math.min.apply(null, yArr), 0], max: [Math.max.apply(null, xArr), Math.max.apply(null, yArr), 0], }; }; @@ -235,7 +235,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b let isIn = false; // 判定点是否在多边形内部,一旦有一个点在另一个多边形内,则返回 - points2.forEach(point => { + points2.forEach((point) => { if (isPointInPolygon(points1, point[0], point[1])) { isIn = true; return false; @@ -244,7 +244,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b if (isIn) { return true; } - points1.forEach(point => { + points1.forEach((point) => { if (isPointInPolygon(points2, point[0], point[1])) { isIn = true; return false; @@ -257,7 +257,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b const lines1 = parseToLines(points1); const lines2 = parseToLines(points2); let isIntersect = false; - lines2.forEach(line => { + lines2.forEach((line) => { if (lineIntersectPolygon(lines1, line)) { isIntersect = true; return false; @@ -276,7 +276,7 @@ export const intersectBBox = (box1: Partial, box2: Partial) => { }; /** - * Whether point is inside the polygon (ray algo) + * Whether point is inside the polygon (ray algo) * @param points * @param x * @param y @@ -320,7 +320,7 @@ export const isPointInPolygon = (points: number[][], x: number, y: number) => { * @param p1 begin of segment line * @param p2 end of segment line * @param q the point to be judged - * @returns + * @returns */ const onSegment = (p1, p2, q) => { if ( @@ -333,11 +333,11 @@ const onSegment = (p1, p2, q) => { return true; } return false; -} +}; const lineIntersectPolygon = (lines, line) => { let isIntersect = false; - lines.forEach(l => { + lines.forEach((l) => { if (getLineIntersect(l.from, l.to, line.from, line.to)) { isIntersect = true; return false; diff --git a/packages/g6/tests/unit/view-spec.ts b/packages/g6/tests/unit/view-spec.ts new file mode 100644 index 0000000000..8c48ee7eea --- /dev/null +++ b/packages/g6/tests/unit/view-spec.ts @@ -0,0 +1,608 @@ +import { Graph, Layout, LayoutMapping } from '@antv/layout'; +import { Circle } from '@antv/g'; +import G6, { IGraph, stdLib } from '../../src/index'; +import { data } from '../datasets/dataset1'; +const container = document.createElement('div'); + +// document.getElementById('__jest-electron-test-results__')?.style.position = 'absolute'; +document.querySelector('body')!.appendChild(container); + +describe('viewport', () => { + let graph: any; + it('should translate viewport without animation correctly.', (done) => { + graph = new G6.Graph({ + container, + width: 500, + height: 500, + type: 'graph', + data, + layout: { + type: 'circular', + center: [250, 250], + radius: 100, + }, + }); + + graph.once('afterlayout', () => { + graph.translate(250, 250); + let [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBe(0); + expect(py).toBe(0); + + graph.translate(-250, -250); + [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBe(250); + expect(py).toBe(250); + + graph.destroy(); + done(); + }); + }); + + it('should translate viewport with animation 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', async () => { + await graph.translate(249, 249, { + duration: 1000, + }); + + graph.once('viewportchange', ({ translate }) => { + expect(translate.dx).toBe(-250); + expect(translate.dy).toBe(-250); + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBe(251); + expect(py).toBe(251); + }); + + await graph.translateTo( + { x: 500, y: 500 }, + { + duration: 2000, + easing: 'ease-in', + }, + ); + + graph.destroy(); + done(); + }); + }); + + it('should zoom viewport without animation 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', async () => { + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(0.5); + expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + }); + await graph.zoom(0.5, { x: 250, y: 250 }); + expect(graph.getZoom()).toBe(0.5); + + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(2); + expect(graph.canvas.getCamera().getZoom()).toBe(1); + }); + await graph.zoom(2, { x: 250, y: 250 }); + + const op = graph.canvas.canvas2Viewport({ x: 450, y: 250 }); + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(1.2); + expect(graph.canvas.getCamera().getZoom()).toBe(1.2); + + // Zoom origin should be fixed. + const { x, y } = graph.canvas.canvas2Viewport({ x: 450, y: 250 }); + expect(x).toBeCloseTo(op.x); + expect(y).toBeCloseTo(op.y); + }); + await graph.zoomTo(1.2, { x: 450, y: 250 }); + expect(graph.getZoom()).toBe(1.2); + + graph.destroy(); + done(); + }); + }); + + it('should zoom viewport with animation 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', async () => { + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(0.5); + expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + }); + await graph.zoom( + 0.5, + { x: 250, y: 250 }, + { + duration: 2000, + }, + ); + + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(2); + expect(graph.canvas.getCamera().getZoom()).toBe(1); + expect(graph.canvas.getCamera().getPosition()[0]).toBe(250); + expect(graph.canvas.getCamera().getPosition()[1]).toBe(250); + }); + await graph.zoom( + 2, + { x: 250, y: 250 }, + { + duration: 2000, + }, + ); + + // const op = graph.canvas.canvas2Viewport({ x: 450, y: 250 }); + // graph.once('viewportchange', ({ zoom }) => { + // expect(zoom.ratio).toBe(3); + // expect(graph.canvas.getCamera().getZoom()).toBe(3); + // // Zoom origin should be fixed. + // const { x, y } = graph.canvas.canvas2Viewport({ x: 450, y: 250 }); + // expect(x).toBeCloseTo(op.x); + // expect(y).toBeCloseTo(op.y); + // }); + // await graph.zoomTo( + // 3, + // { x: 450, y: 250 }, + // { + // duration: 1000, + // }, + // ); + + // graph.once('viewportchange', ({ zoom }) => { + // expect(zoom.ratio).toBe(1 / 3); + // expect(graph.canvas.getCamera().getZoom()).toBe(1); + // expect(graph.canvas.getCamera().getPosition()[0]).toBe(250); + // expect(graph.canvas.getCamera().getPosition()[1]).toBe(250); + // }); + // await graph.zoomTo( + // 1, + // { x: 250, y: 250 }, + // { + // duration: 1000, + // }, + // ); + + graph.destroy(); + done(); + }); + }); + + it('should rotate viewport 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', async () => { + graph.once('viewportchange', ({ rotate }) => { + expect(rotate.angle).toBe(30); + expect(graph.canvas.getCamera().getRoll()).toBe(30); + }); + await graph.rotateTo(30, undefined, { + duration: 1000, + }); + + graph.once('viewportchange', ({ rotate }) => { + expect(rotate.angle).toBe(30); + expect(graph.canvas.getCamera().getRoll()).toBe(60); + }); + await graph.rotateTo(60, undefined, { + duration: 1000, + }); + + graph.once('viewportchange', ({ rotate }) => { + expect(rotate.angle).toBe(-30); + expect(graph.canvas.getCamera().getRoll()).toBe(30); + }); + await graph.rotateTo(30, undefined, { + duration: 1000, + }); + + graph.once('viewportchange', ({ rotate }) => { + expect(rotate.angle).toBe(-29); + expect(graph.canvas.getCamera().getRoll()).toBe(1); + }); + // without animation + await graph.rotate(-29); + + graph.destroy(); + done(); + }); + }); + + it('should transform viewport without animation 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', async () => { + const origin = new Circle({ + style: { + cx: 450, + cy: 250, + r: 30, + fill: 'green', + opacity: 0.5, + }, + }); + graph.canvas.appendChild(origin); + + // graph.once('viewportchange', ({ zoom }) => { + // expect(zoom.ratio).toBe(0.5); + // expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + // }); + // await graph.transform({ + // zoom: { + // ratio: 0.5, + // }, + // origin: { x: 250, y: 250 }, + // }); + + // graph.once('viewportchange', ({ zoom, rotate }) => { + // expect(zoom.ratio).toBe(2); + // expect(rotate.angle).toBe(30); + // expect(graph.canvas.getCamera().getZoom()).toBe(1); + // expect(graph.canvas.getCamera().getRoll()).toBe(30); + // }); + // await graph.transform({ + // zoom: { + // ratio: 2, + // }, + // rotate: { + // angle: 30, + // }, + // origin: { x: 250, y: 250 }, + // }); + + // graph.once('viewportchange', ({ rotate }) => { + // expect(rotate.angle).toBe(-30); + // expect(graph.canvas.getCamera().getZoom()).toBe(1); + // expect(graph.canvas.getCamera().getRoll()).toBe(0); + // }); + // await graph.transform({ + // rotate: { + // angle: -30, + // }, + // origin: { x: 250, y: 250 }, + // }); + + graph.once('viewportchange', ({ translate }) => { + expect(translate.dx).toBe(-250); + expect(translate.dy).toBe(-250); + // expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + // expect(graph.canvas.getCamera().getRoll()).toBe(0); + }); + await graph.transform({ + translate: { + dx: -250, + dy: -250, + }, + }); + + graph.once('viewportchange', ({ zoom }) => { + expect(zoom.ratio).toBe(0.5); + expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + }); + await graph.transform({ + zoom: { + ratio: 0.5, + }, + origin: { x: 0, y: 0 }, + }); + + await graph.transform({ + translate: { + dx: 250, + dy: 250, + }, + zoom: { + ratio: 4, + }, + }); + await graph.transform({ + translate: { + dx: -250, + dy: -250, + }, + }); + + // graph.once('viewportchange', ({ rotate }) => { + // // expect(rotate.angle).toBe(0); + // expect(graph.canvas.getCamera().getZoom()).toBe(0.5); + // // expect(graph.canvas.getCamera().getRoll()).toBe(0); + // }); + // await graph.transform({ + // // rotate: { + // // angle: 0, + // // }, + // zoom: { + // ratio: 1, + // }, + // origin: { x: 450, y: 250 }, + // }); + + graph.destroy(); + origin.destroy(); + done(); + }); + }); + + it('should stop the current transition of transform 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', async () => { + graph.once('viewportchange', ({ translate }) => { + expect(translate.dx).toBe(-250); + expect(translate.dy).toBe(-250); + }); + await graph.transform( + { + translate: { + dx: -250, + dy: -250, + }, + }, + { + duration: 2000, + }, + ); + graph.stopTransformTransition(); + + graph.destroy(); + done(); + }); + }); + + it('should fitCenter with transition 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', async () => { + await graph.translate(249, 249, { + duration: 1000, + }); + + graph.once('viewportchange', ({ translate }) => { + expect(translate.dx).toBeCloseTo(-249); + expect(translate.dy).toBeCloseTo(-249); + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(250); + expect(py).toBeCloseTo(250); + }); + + await graph.fitCenter({ + duration: 2000, + easing: 'ease-in', + }); + + await graph.zoom(0.5); + await graph.translate(249, 249); + graph.once('viewportchange', ({ translate }) => { + expect(translate.dx).toBeCloseTo(-249); + expect(translate.dy).toBeCloseTo(-249); + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(250); + expect(py).toBeCloseTo(250); + }); + await graph.fitCenter(); + + graph.destroy(); + done(); + }); + }); + + it('should focusItem with transition 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', async () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].id).toBe('Argentina'); + expect(nodesData[0].data.x).toBe(450); + expect(nodesData[0].data.y).toBe(250); + + expect(nodesData[4].id).toBe('Colombia'); + expect(nodesData[4].data.x).toBeCloseTo(391.421356237309); + expect(nodesData[4].data.y).toBeCloseTo(391.4213562373095); + + graph.once('viewportchange', () => { + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(450); + expect(py).toBeCloseTo(250); + }); + await graph.focusItem('Argentina', { + duration: 1000, + easing: 'ease-in', + }); + + graph.once('viewportchange', () => { + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(391.421356237309); + expect(py).toBeCloseTo(391.4213562373095); + }); + await graph.focusItem('Colombia', { + duration: 1000, + }); + + await graph.focusItem('Spain', { + duration: 1000, + easing: 'ease-in', + }); + + graph.once('viewportchange', () => { + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBe(450); + expect(py).toBe(250); + }); + await graph.focusItem('Argentina'); + + graph.once('viewportchange', () => { + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(250); + expect(py).toBeCloseTo(250); + }); + await graph.focusItem(nodesData.map((node) => node.id)); + + graph.focusItem('Non existed node'); + + graph.destroy(); + done(); + }); + }); + + it('should fitView with transition 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', async () => { + const nodesData = graph.getAllNodesData(); + expect(nodesData[0].id).toBe('Argentina'); + expect(nodesData[0].data.x).toBe(450); + expect(nodesData[0].data.y).toBe(250); + + expect(nodesData[4].id).toBe('Colombia'); + expect(nodesData[4].data.x).toBeCloseTo(391.421356237309); + expect(nodesData[4].data.y).toBeCloseTo(391.4213562373095); + + graph.once('viewportchange', () => { + const [px, py] = graph.canvas.getCamera().getPosition(); + expect(px).toBeCloseTo(450); + expect(py).toBeCloseTo(250); + }); + await graph.focusItem('Argentina', { + duration: 1000, + easing: 'ease-in', + }); + await graph.zoom(0.5, undefined, { + duration: 1000, + easing: 'ease-in', + }); + await graph.fitView( + { + padding: [50, 50, 50, 50], + }, + { + duration: 1000, + easing: 'ease-in', + }, + ); + await graph.translate(100, 100, { + duration: 1000, + easing: 'ease-in', + }); + await graph.fitView( + { + padding: [150, 100], + rules: { + direction: 'both', + ratioRule: 'min', + }, + }, + { + duration: 1000, + easing: 'ease-in', + }, + ); + await graph.fitView(undefined, { + duration: 1000, + easing: 'ease-in', + }); + + graph.destroy(); + done(); + }); + }); +});